/* App shell: bottom bar on phones, side rail in tablet landscape, sidebar on
   desktop. Driven by orientation as well as width — an iPad on a mic stand is
   landscape and short, which is a different problem from a wide desktop.

   Sizing assumes the device is on a stand, read from about half a metre and
   tapped one-handed while holding a guitar. Legibility and target separation
   beat thumb reach.

   NO RAW COLOUR IN THIS FILE. Every colour is a semantic token from
   css/themes.generated.css. Every weight and tracking is a type token, because
   "one step heavier on Light" is a property of the environment, not of a
   button. See docs/THEMING.md. */


/* ============ THE SHAPE TOKENS SURVIVE THEIR STYLESHEET BEING LATE =========
 *
 * THE FAULT THIS FIXES: modals drawn with SQUARE CORNERS, intermittently, on
 * a slow connection. Not a styling decision and not one modal — every rounded
 * thing in the app goes square at the same moment, and the moment is a
 * loading race.
 *
 * THE MECHANISM, MEASURED RATHER THAN REASONED ABOUT. dashboard/index.html
 * ships all five stylesheets as media="print" so they cannot block the first
 * frame, and js/css-boot.js puts each one back to media="all" AS THAT ONE
 * LOADS. Its header is right that this preserves the CASCADE, because a
 * promoted sheet takes its place in the cascade by DOM position. What it does
 * not preserve is the ORDER SHEETS BECOME ACTIVE IN. The sheet that DEFINES
 * the design tokens (css/themes.generated.css) is promoted independently of
 * the four that CONSUME them, so there is a window in which the consumers are
 * live and their tokens are not.
 *
 * In that window `var(--radius-lg)` references a custom property with no
 * value. That is not "nothing happens": an unresolvable var() makes the whole
 * declaration INVALID AT COMPUTED-VALUE TIME, and an invalid non-inherited
 * property falls back to its INITIAL value. The initial value of border-radius
 * is 0. Square corners, everywhere, until the token sheet lands.
 *
 * Reproduced on 15 August 2026 with css/themes.generated.css delayed 2600ms
 * by route interception and everything else served normally:
 *
 *   t= 235ms  themes.generated=print  shared/shell/dashboard/switcher=all
 *             --radius-lg = (UNDEFINED)      .vm border-radius = 0px
 *   t=2790ms  themes.generated=all    (all)
 *             --radius-lg = 18px             .vm border-radius = 18px
 *
 * WHY THE FIX IS HERE AND NOT IN css-boot.js. Promoting the five as a group
 * would also close it, and that change belongs to the owner of that file — it
 * is written up in the report. But it would only close it for the five sheets
 * that exist today. The durable rule is the one the head already applies to
 * the canvas colour ("First paint, before any stylesheet, so the canvas is
 * never white"): A SHEET MUST BE ABLE TO DRAW ITSELF BEFORE ITS REFINEMENTS
 * ARRIVE. @property does exactly that for a custom property — registering it
 * gives it an initial value, so a var() reference to it can never be
 * unresolvable. It is no longer "undefined then 18px", it is "18px then
 * whatever the theme says", which is the same relationship the inline canvas
 * colour has with the real background.
 *
 * ONLY SHAPE AND RHYTHM ARE REGISTERED, never colour. A wrong colour is worse
 * than an unstyled one and this file is forbidden from carrying colour at all;
 * a wrong radius is invisible. The values are the Dark/Light figures from
 * css/themes.generated.css, which is also what its own bare `:root` fallback
 * block uses. Stage's tighter radii and any theme's density arrive with the
 * token sheet and win normally, because a registered property is still just a
 * custom property: any author declaration beats its initial value.
 *
 * Repeated verbatim at the top of css/dashboard.css. That is not duplication
 * for its own sake — the whole point is that a sheet does not depend on
 * ANOTHER network resource for the values it needs in order to draw, and
 * dashboard.css can be live while this file is not.
 *
 * A browser without @property support ignores these and behaves exactly as it
 * does today, so this cannot make anything worse.                           */

@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 {
  /* THE MIDDLE RADIUS, WHICH NOTHING DEFINED. `.sw-update-notice .swu-dismiss`
     further down this file has always asked for var(--radius-md), and no theme
     and no stylesheet has ever set it — so that control has been drawn with
     border-radius: 0 since the day it was written. A permanently square corner
     rather than the intermittent one above, found by auditing every var() in
     this file and css/dashboard.css against every token actually defined.
     Derived from --radius rather than hard-coded, so it follows Stage down to
     10px like everything else. */
  --radius-md: calc(var(--radius) - 4px);
}

:root {
  --nav-size: 68px;
  /* HOW FAR THE GO LIVE CIRCLE STANDS ABOVE THE BAR, in one place, because
     three rules need to agree about it: the button's lift, the circle's size,
     and the bottom padding .shell-main leaves so the last thing on a page is
     not touched by a control floating over it. See .nav-golive. */
  --golive-rise: 7px;
  --rail-width: 96px;
  --sidebar-width: 232px;

  /* THE FOUR INSETS, NAMED ONCE.
   *
   * Two reasons this is a variable rather than env() written out at each use
   * site. The first is that "how much of this edge belongs to the hardware" is
   * one idea and should read as one. The second is testability: no browser
   * automation can emulate a notch, so env() is always 0 under Playwright and
   * every safe-area rule in this file would be permanently untested. Because
   * the padding is computed from these names, tests/pwa-safe-areas.mjs can set
   * them to an iPhone 14 Pro's real numbers and check that the maths does the
   * right thing with them. */
  --safe-top: env(safe-area-inset-top, 0px);
  --safe-right: env(safe-area-inset-right, 0px);
  --safe-bottom: env(safe-area-inset-bottom, 0px);
  --safe-left: env(safe-area-inset-left, 0px);
}


/* ================= A BUTTON IS A SHAPE, NOT ONLY A COLOUR =================
 *
 * THE FAULT THIS FIXES: "buttons in that modal have no padding and do not
 * match the rest of the app". It is not a modal fault. It is what happens
 * anywhere in the dashboard that nobody wrote a second rule.
 *
 * WHAT WAS ACTUALLY WRONG. css/shared.css defines .btn-primary, .btn-quiet
 * and .btn-ghost as COLOUR ROLES and nothing else — a background, a text
 * colour, a border on the outlined ones. No padding, no height, no line-box.
 * The bare `button` rule above them sets a radius and a font and explicitly
 * `border: 0`, and also says nothing about padding. So a .btn-primary is, by
 * itself, a coloured rectangle the exact size of its text plus whatever the
 * user agent felt like — in Chrome that is `padding: 1px 6px`.
 *
 * Every button in this app that looks right looks right by ACCIDENT of some
 * ancestor: `.songs-selectbar .btn-primary { padding: 0 16px }`,
 * `.songs-tools button { height: var(--db-field); padding: 0 18px }`,
 * `.songs-drawer-actions button { flex: 1; height: var(--db-field-sm) }`.
 * Put the same class anywhere those selectors do not reach and it collapses.
 * Modal bodies are exactly that place, which is why the report came from a
 * modal, but they are not the only one.
 *
 * MEASURED, 15 August 2026, Chromium, Dark, 1440x900 — same two classes:
 *
 *   #songSelectAdd   .btn-primary  in .songs-selectbar   padding 0 16px   44px tall
 *   #bookBriefGo     .btn-primary  in a modal            padding 1px 6px  22px tall
 *   #songSelect      .btn-quiet    in .songs-tools       padding 0 18px   48px tall
 *   #bookBriefBack   .btn-quiet    in a modal            padding 1px 6px  22px tall
 *   #bookNew         .btn-quiet    on the Songbooks tab  padding 1px 6px
 *   #manualSave      .btn-primary  in the Add drawer     padding 1px 6px
 *
 * A 22-pixel primary action is not a small button, it is not a button.
 *
 * THE FIX IS THE MISSING HALF OF THE ROLE, not a rule per modal. A named
 * button role has to carry its own geometry or it is not a role. This gives
 * the four roles a real shape once, in the shell, so every one of them is a
 * button wherever it is put — including places nobody has written yet.
 *
 * SPECIFICITY IS DELIBERATELY THE LOWEST THAT WORKS: one class, in a sheet
 * that loads after shared.css and before dashboard.css. Every existing
 * context rule is either more specific (`.songs-selectbar .btn-primary`,
 * two classes) or later (`css/dashboard.css`), so nothing already tuned
 * moves. Verified: no measurement outside a modal changed.
 *
 * 44px because that is the floor this app uses everywhere a musician has to
 * hit something, and .vm-x two files away already says so in as many words.
 * inline-flex + centre because a `height` on a button with no line-box of its
 * own leaves the label sitting on the baseline it happened to inherit.       */

.btn-primary,
.btn-quiet,
.btn-ghost,
.btn-danger {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  min-height: var(--db-field-sm, 44px);
  padding: 8px 18px;
  border-radius: var(--radius);
  font-size: var(--db-t-body, 14px);
  font-weight: var(--weight-medium);
  line-height: 1.25;
  text-align: center;
  /* A label that cannot fit still has to be readable: wrap it rather than let
     it run out of the button or force the button past its container. */
  min-width: 0;
  max-width: 100%;
  overflow-wrap: anywhere;
}

/* The outlined roles pay for their border out of their own padding so an
   outlined and a filled button of the same label are the same size. */
.btn-quiet,
.btn-ghost {
  padding-inline: 17px;
}

/* Disabled has to look disabled at arm's length under stage lighting, and
   shared.css's .6 opacity alone was doing that job on Light where the drop
   from a filled action to 60% of it is small. Not a colour change — the
   cursor and the opacity together. */
.btn-primary:disabled,
.btn-quiet:disabled,
.btn-ghost:disabled,
.btn-danger:disabled { cursor: not-allowed; }

/* THE ROLE IS NOT ONLY FOR <button>. The file importer's picker is a <label>
   carrying .btn-primary, because a raw <input type=file> is drawn differently
   by every browser and cannot be made to match. A label has no pointer cursor
   of its own, and a control that does not say it is clickable is the same
   fault this block exists to fix. */
.btn-primary,
.btn-quiet,
.btn-ghost,
.btn-danger { cursor: pointer; }


/* A LABEL FOR ASSISTIVE TECHNOLOGY AND NOTHING ELSE.
 *
 * Needed here rather than assumed: js/views/songs.js puts .visually-hidden on
 * the file input that .import-choose stands in front of, and until now NO
 * stylesheet loaded by the dashboard defined it — css/public.css has .sr-only,
 * but that file is only loaded by the punter's page. An undefined class is not
 * an error, it is simply nothing, so the raw file input was drawn on the page
 * next to the button that exists to replace it.
 *
 * The clip-path/clip pair rather than display:none or visibility:hidden:
 * both of those take the element out of the accessibility tree as well as out
 * of the layout, and this element still has to be focusable and still has to
 * be what the label points at.
 *
 * !important ON EVERY LINE, WHICH IS EARNED RATHER THAN LAZY. This is a
 * utility whose entire job is to beat whatever the element would otherwise
 * be, and it is applied to elements sitting inside components that style
 * their own descendants. Measured before the flags were added: the file input
 * came out 1x44 rather than 1x1, because css/dashboard.css says
 * `.songs-drawer input { height: var(--db-field-sm) }` — two components deep,
 * more specific than a single class, and it wins. A utility that can be
 * out-specified is a utility that fails silently in exactly the places it
 * matters. */
.visually-hidden {
  position: absolute !important;
  width: 1px !important;
  height: 1px !important;
  min-height: 0 !important;
  margin: -1px !important;
  padding: 0 !important;
  overflow: hidden !important;
  clip: rect(0 0 0 0) !important;
  clip-path: inset(50%) !important;
  white-space: nowrap !important;
  border: 0 !important;
}

.app-shell {
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}

/* THE HEADER SAT UNDER THE CLOCK ON AN iPAD.
 *
 * apple-mobile-web-app-status-bar-style is black-translucent and the viewport
 * is viewport-fit=cover, which together mean the page owns the whole screen
 * INCLUDING the strip the status bar floats over. That is the right choice —
 * the alternative is a dead black band above the app — but it is only right if
 * the top of the layout then keeps out of the way, and this header did not.
 *
 * max(), not addition: on a device with no inset at all the header keeps its
 * original 14px and nothing moves. On one with an inset the padding becomes
 * the inset, which is by definition exactly enough. */
.shell-header {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: max(14px, var(--safe-top)) calc(18px + var(--safe-right))
           14px calc(18px + var(--safe-left));
  border-bottom: 1px solid var(--border);
  background: var(--bg);
  position: sticky;
  top: 0;
  z-index: 20;
}
/* AND MORE ROOM AGAIN ON A TABLET. This is the iPad complaint specifically.
   On a phone the inset is the whole story; on an iPad the status bar sits in a
   wider, taller strip and a header padded to exactly the inset still reads as
   crammed against the clock. Eight points clear of it, or 20px on an iPad with
   no inset at all, whichever is more. */
@media (min-width: 768px) {
  .shell-header { padding-top: max(20px, calc(var(--safe-top) + 8px)); }
}

/* The AnyRequests mark, 22px, left of the act name. Square box and `contain`,
   exactly as .brand-mark and .about-mark do it in css/shared.css: the bars are
   wider than they are tall, so they draw 22 across and about 16 down inside
   it. Painted from the theme token rather than an <img> with a baked src, so
   Dark, Light and Stage each bring their own artwork. */
.shell-mark {
  width: 22px;
  height: 22px;
  flex: none;
  background: var(--brand-slip) center / contain no-repeat;
}
.shell-act { font-weight: var(--weight-strong); font-size: 17px; }
/* THE NAME MAY SHRINK AND THE PILL MAY NOT. Before the Pro pill the name was
   the only thing between the menu button and the live status, and a long one
   could already run under the status at 320px because a flex item will not
   shrink below its content unless told. Told now: the name gives way with an
   ellipsis, the pill keeps its size, and the header stays one line. The name
   does not move, because the pill sits after it rather than before. Measured
   at 320px, 390px and 1280px before this landed; see the task 1 commit. */
.shell-header .shell-act { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.shell-header .pro-pill { margin-left: -4px; }
/* AND THE STATUS MAY NOT SHRINK EITHER. Measured with a 43-character act name
   at 320px and 390px: the name squeezed "Off air" onto two lines and the header
   grew from 63px to 68px, on a STANDARD account with no pill anywhere. That was
   there before the pill and is fixed with it, because the rule both answer to
   is the same one: the header is one line, and the name is what gives way. */
.shell-header .shell-status { flex: none; white-space: nowrap; }
.shell-status {
  margin-left: auto;
  display: inline-flex;
  align-items: center;
  gap: 7px;
  font-size: 13px;
  color: var(--text-secondary);
}
.shell-status .dot {
  width: 9px; height: 9px; border-radius: 50%;
  background: var(--text-secondary);
}
/* Live is never signalled by the dot's colour alone: the word beside it reads
   "Live" or "Off air", and the dot itself pulses. Colour is the third cue. */
.shell-status.is-live { color: var(--positive); font-weight: var(--weight-medium); }
.shell-status.is-live .dot { background: var(--positive); animation: livePulse 1.8s ease-in-out infinite; }
@keyframes livePulse { 50% { opacity: .35; } }

/* ============ THE MENU BUTTON SAYS "MENU" (9 October 2026) ============
 *
 * Mark: many musicians missed the bare icon at the top left. It is now the
 * icon and the word, 44px tall (the tap-target floor), with an edge in the
 * strong border colour and the label in the accent colour that reads on the
 * page, so it is unmistakably a button. No motion. The word is the accessible
 * name; the icon is aria-hidden.
 *
 * AT 320px THE HEADER STILL FITS ON ONE LINE: under 360px the brand mark
 * beside the act name steps aside (it is decoration, aria-hidden), which
 * gives back the room the word takes. The act name was already the thing
 * that gives way, with an ellipsis. */
.shell-menu-button {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 44px;
  min-width: 44px;
  /* The 44px target reaches into the header's own padding rather than making
     the header taller: it stays the height it was before the word was added. */
  margin-block: -5px;
  padding: 0 12px 0 10px;
  border: 1.5px solid var(--border-strong);
  border-radius: 12px;
  background: transparent;
  color: var(--action-on-surface);
  font: inherit;
  font-size: 15px;
  font-weight: var(--weight-strong);
  line-height: 1;
}
.shell-menu-icon { font-size: 20px; line-height: 1; }
.shell-menu-button:focus-visible { outline: 2px solid var(--action); outline-offset: 2px; }
@media (max-width: 359px) {
  .shell-header .shell-mark { display: none; }
  .shell-menu-button { padding: 0 10px 0 8px; }
}

.shell-main {
  flex: 1;
  width: 100%;
  max-width: 900px;
  margin: 0 auto;
  /* THE BOTTOM ONE CLEARS THE RAISED BUTTON, not just the bar. It was a flat
     34 against a 20px rise, so the clearance was real but accidental: raising
     the circle to 26 without touching this would have quietly eaten most of
     it, and the next change to either number would have finished the job. The
     rise is a token now and the air above it is stated, so the two cannot
     drift apart without somebody deciding to. */
  padding: 18px calc(18px + var(--safe-right))
           calc(var(--nav-size) + var(--safe-bottom) + var(--golive-rise) + 12px)
           calc(18px + var(--safe-left));
}

/* ----------------------------------------------------------- bottom bar --- */

.shell-nav {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  z-index: 30;
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  align-items: end;
  background: var(--surface);
  border-top: 1px solid var(--border);
  /* Without this the bar sits under the iPhone home indicator. The 8px floor is
     for the devices with no indicator at all, where the labels otherwise ran
     into the bottom edge of the screen. */
  padding-bottom: max(8px, var(--safe-bottom));
  padding-left: var(--safe-left);
  padding-right: var(--safe-right);
}

.nav-item {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
  height: var(--nav-size);
  color: var(--text-secondary);
  text-decoration: none;
  font-size: 11px;
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-normal);
  background: transparent;
  border: 0;
}
.nav-item svg { width: 23px; height: 23px; fill: currentColor; }

/* WHICH TAB AM I ON. This used to be a colour change and nothing else at phone
   and rail sizes — the background pill only existed on the desktop sidebar, so
   on the device this app is actually used on, the selected tab differed from
   the other four by hue alone. It now carries a marker bar and a heavier
   label at every size; the accent colour is the third cue, not the only one. */
.nav-item::before {
  content: '';
  position: absolute;
  top: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 34px;
  height: 3px;
  border-radius: 0 0 3px 3px;
  background: var(--action-on-surface);
  opacity: 0;
}
.nav-item.active { color: var(--action-on-surface); font-weight: var(--weight-strong); }
.nav-item.active::before { opacity: 1; }

/* The centre button is both the action and the status light. */
/* ============ GO LIVE RISES ABOVE THE BAR ============
 *
 * It was supposed to already, and it did not. The circle carried
 * `margin-top: -22px` inside a column flex box whose content is justified to
 * flex-end: the child is already against the bottom, so a negative top margin
 * only takes away space above it and moves nothing. Measured on a phone, an
 * iPad in portrait and a 430px phone, the circle's top sat SEVEN PIXELS BELOW
 * the top of the bar, which is not a raised control, it is a round tab.
 *
 * position: relative on the BUTTON rather than a transform on the circle, and
 * on the button rather than the circle, for two reasons. The layout box stays
 * where it is, so nothing else in the bar moves. And the tap target rises with
 * the thing it belongs to: lifting the circle alone would have left the top
 * third of it outside its own button, which is the sort of fault nobody finds
 * by looking.
 *
 * THE SAFE AREA IS UNDERNEATH, NOT ABOVE. The bar already pads itself by
 * max(8px, var(--safe-bottom)), and this rise moves the circle away from the
 * home indicator rather than towards it. tests/golive-rises.mjs measures the
 * gap with an inset simulated. */
.nav-golive {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: flex-end;
  /* ============ THE TAP TARGET HAS TO CONTAIN THE CIRCLE ============
   *
   * The circle is 78 and the tab row is 68, so a button of exactly one row
   * height leaves the top ten pixels of the circle outside its own button:
   * pressable to look at, dead to a thumb, and invisible to anybody testing
   * by eye. The button is as tall as the circle instead.
   *
   * THE NEGATIVE MARGIN IS WHAT KEEPS THE BAR THE SAME HEIGHT. .shell-nav is
   * a grid with align-items: end, and an auto row is sized by its items'
   * MARGIN boxes: 78 - 12 is 66, under the 68 the four tabs already ask for,
   * so the row does not grow and none of them moves. Take the margin away and
   * the whole bar gets ten pixels taller on every screen. */
  /* AS TALL AS THE CIRCLE, so the tap target contains it. The circle is 96
     and the row is 68, so a button of one row height leaves nearly a third of
     it outside its own button: pressable to look at, dead to a thumb, and
     invisible to anybody testing by eye.
     THE NEGATIVE MARGIN IS WHAT KEEPS THE BAR THE SAME HEIGHT. .shell-nav is
     a grid with align-items: end and an auto row is sized by its items' MARGIN
     boxes: 100 minus 34 is 66, under the 68 the four tabs already ask for, so
     the row does not grow and no label moves. Take the margin away and every
     screen's bar gets thirty pixels taller. */
  height: calc(var(--nav-size) + 32px);
  margin-top: -34px;
  background: transparent;
  border: 0;
  padding: 0;
  position: relative;
  /* ============ THIRD PASS: SUNK INTO THE BAR, NOT PERCHED ON IT ==========
   *
   * Two passes got this wrong in the same direction. A 62px circle lifted 27
   * read as a disc balanced on the bar. A 78px circle lifted 16 read as a
   * bigger disc balanced on the bar. Both were the same mistake: a circle
   * roughly the bar's own height with a large piece of it above the line looks
   * like a separate object somebody put there.
   *
   * The object wanted is a button TOO LARGE FOR THE BAR TO CONTAIN, sitting
   * IN it, with a small cap showing above the line. So the circle went up and
   * the protrusion came down, which is the opposite of what the first two
   * passes did to the second number.
   *
   * THE ARITHMETIC, so the next person changing one knows what the other does:
   *
   *     proud = circle + lift - row
   *     7 = 96 + (-21) - 68
   *
   * A NEGATIVE LIFT, so `top` is POSITIVE: the circle is pushed DOWN. It is
   * 41 per cent taller than the row it sits in, so anchoring it to the row's
   * foot would put 28px above the line; pushing it down 21 leaves 7.
   *
   * ITS BOTTOM GOES BELOW THE BAR, AND THAT IS ACCEPTED. On a device with a
   * home indicator the bar's own padding swallows it entirely. On one without,
   * about 13px of the circle is clipped by the bottom of the screen. That is a
   * deliberate trade: the size is the point, and shrinking the circle to avoid
   * a clipped edge would give back the whole effect. Nothing ELSE on the bar
   * is obscured, which is what the safe-area rule is actually about, and
   * tests/golive-rises.mjs measures the four tabs to prove it. */
  top: 21px;
}
/* Flat, opaque, no glow. The old treatment was a coloured drop shadow that
   bloomed on dark and pulsed while live; a soft accent halo is the one thing
   docs/THEMING.md names as the look to avoid, and on a dark theme it is also a
   halation source. Separation is the knockout ring against the bar instead. */
.golive-circle {
  /* 96, and 41 per cent taller than the 68px tab row on purpose. See
     .nav-golive above.
     flex: none BECAUSE IT IS TALLER THAN ITS PARENT. The button is a column
     flex box of exactly one row height, so a flex item asking for more than
     that is shrunk to fit by default: setting 78 without this produced a 68px
     circle and looked like the change had not been made. */
  flex: none;
  width: 96px; height: 96px;
  /* The margin-top: -22px that used to be here did nothing at all. Its parent
     is a column flex box justified to flex-end, so the circle was already
     against the bottom and a negative top margin only took away space above
     it. The rise is on .nav-golive now, where it also moves the tap target. */
  border-radius: 50%;
  background: var(--action);
  color: var(--action-text);
  display: grid;
  place-items: center;
  /* ============ THE LABEL BELONGS TO THE BUTTON ============
   *
   * It was sentence case at the body tracking, which is how a word is set
   * inside a paragraph. In a 96px disc of solid accent that reads as text
   * somebody dropped into a shape rather than as the legend on a control, and
   * it was the one thing on the bar still looking under-designed after the
   * circle got its size.
   *
   * CAPS AND TRACKING, which is what a legend on a control is. Twelve rather
   * than thirteen because caps are measured by their cap height and mixed case
   * by its x-height: 12px caps stand 8.6px tall against 13px sentence case at
   * 6.8, so the label reads LARGER while the string gets shorter. The tracking
   * is the app's own --tracking-wide rather than a number invented here.
   *
   * text-transform rather than a rewritten string, because the case of a label
   * is a typographic decision and the words are not changing. renderLiveState()
   * still writes "Go Live" and "LIVE" and docs/COPY-FOR-APPROVAL.md is
   * untouched by this rule.
   *
   * THE INDENT IS NOT A FUDGE. letter-spacing adds its space AFTER the last
   * letter as well as between, so a centred line sits half a tracking unit to
   * the left of where it looks like it should. Half of .10em put back. */
  font-size: 12px;
  font-weight: var(--weight-strong);
  line-height: 1.1;
  text-align: center;
  text-transform: uppercase;
  letter-spacing: var(--tracking-wide);
  text-indent: .05em;
  /* ============ THE RING IS THE CUTOUT ============
   *
   * --bg is the PAGE colour and the bar is --surface, so this ring is a hole
   * punched through the bar rather than an outline drawn on it: the circle
   * reads as passing through the bar rather than sitting on it, which is the
   * whole difference between a control the bar cannot contain and a disc
   * balanced on the edge.
   *
   * 4px rather than 3, scaled with the circle. At 3 against 78 the ring
   * disappeared into the border-top of the bar at the two points where they
   * meet, and the cutout stopped reading as one.
   *
   * The shadow does the rest: it is the app's own elevation token and not a
   * coloured glow, which docs/THEMING.md names as the look to avoid. */
  box-shadow: var(--shadow-1);
  border: 4px solid var(--bg);
}
/* Live also changes the label from "Go Live" to "LIVE", so this ring is a
   reinforcement rather than the signal. It is a hard edge that blinks between
   the accent and the bar's own surface — instrumentation, not a glow. */
.nav-golive.is-live .golive-circle {
  animation: golivePulse 1.9s ease-in-out infinite;
  /* ============ AND THE LIVE LABEL IS SET DIFFERENTLY, NOT JUST SPELLED
     DIFFERENTLY ============
     Two states told apart by which four letters are showing is a difference
     you have to read. LIVE is a quarter of the length of GO LIVE, so it can
     carry more than twice the tracking in less room, and a short word opened
     right out is the one setting nothing else in this app uses. Bigger as
     well: the button says one thing when it is armed and a different kind of
     thing when it is on air, and at a glance across a stage the difference is
     the SHAPE of the line rather than its spelling. Half of .26em put back. */
  font-size: 14px;
  letter-spacing: .26em;
  text-indent: .13em;
}
/* ============ AND ON A PANEL, THE APP'S OWN LIVE DOT ============
 *
 * Defined here and switched on in the two desktop blocks, because the union of
 * their media queries is not a query: a 1000x900 landscape window matches
 * neither and gets the bottom bar, so a combined rule would put panel styling
 * on a bar circle. One declaration duplicated beats ten.
 *
 * WHY THE PANEL NEEDS IT AND THE BAR DOES NOT. On the bar the two states are
 * told apart by the word and by a ring blinking against the bar's own surface,
 * and the bar is two inches from the musician's hand. The panel is across a
 * room on a laptop, the rectangle has no ring cut through anything, and two
 * filled pills of the same colour are not two states until you read them. This
 * is the same dot the header already uses to say the same thing, so it is the
 * app's existing vocabulary rather than a new signal invented for one control.
 *
 * STEADY, NOT PULSING. The ring already pulses; a second thing pulsing next to
 * it in the same button is not twice as clear. */
.nav-golive.is-live .golive-circle::before {
  display: none;
  content: '';
  width: 9px;
  height: 9px;
  border-radius: 50%;
  /* The ink the label is set in, because it is on the accent fill with it. */
  background: currentColor;
}
@keyframes golivePulse {
  0%, 100% { box-shadow: 0 0 0 3px var(--action); }
  50%      { box-shadow: 0 0 0 3px var(--surface); }
}

/* -------------------------------------------------------------- drawer --- */

.shell-drawer-overlay {
  position: fixed; inset: 0; z-index: 40;
  /* A scrim, which is the one place transparency is still the right answer:
     it has to darken whatever is behind it. */
  background: var(--surface-overlay);
}
.shell-drawer-overlay[hidden] { display: none; }
.shell-drawer {
  position: absolute; top: 0; bottom: 0; left: 0;
  width: min(290px, 84vw);
  background: var(--surface);
  /* THE ONE EDGE THE DRAWER HAS, and it is the edge that faces the scrim. Same
     fault as .gm-modal below: --surface-overlay darkens the page BEHIND it, so
     the drawer is separated from the page by the ladder and from the wash on
     top of it by nothing — 1.17:1 on Dark, 1.13 on Stage. This is worse than a
     modal rather than better, because a drawer has no shadow on its leading
     side and three of its four edges are off-screen, so this rule is the whole
     boundary. --border-strong reads 4.02 and 3.90 against those scrims.
     The --border rules on .shell-nav further down are deliberately left alone:
     that rail divides two opaque planes and has no scrim anywhere near it. */
  border-right: 1px solid var(--border-strong);
  padding: max(20px, var(--safe-top)) 16px calc(20px + var(--safe-bottom));
  display: flex;
  flex-direction: column;
  gap: 4px;
  animation: drawerIn .18s ease-out;
}
@keyframes drawerIn { from { transform: translateX(-100%); } }
.drawer-title {
  font-size: 12px;
  color: var(--text-secondary);
  text-transform: uppercase;
  letter-spacing: var(--tracking-wide);
  margin: 6px 8px 10px;
}
.drawer-item {
  display: flex; align-items: center; gap: 12px;
  padding: 14px 12px;
  border-radius: 12px;
  color: var(--text-primary);
  text-decoration: none;
  font-size: 15px;
  font-weight: var(--weight-medium);
  background: transparent;
  border: 0;
  width: 100%;
  text-align: left;
}
.drawer-item:hover { background: var(--surface-raised); }

/* ============ THE ADMIN ENTRY, IN AMBER, FOR TWO PEOPLE ============
 *
 * --notice is the amber this app already uses for "look at this, it is not an
 * error". The entry is a link out of the musician app into a different one, and
 * it is in the same menu as Settings and Log out, so it has to read as
 * something other than another errand.
 *
 * IT IS NOT HIDDEN FROM ANYBODY BY THIS RULE. js/dashboard-shell.js does not
 * BUILD the item unless public.viewer_is_super_admin() has said yes, so for
 * everybody else there is no element for this to style. A CSS rule that hid it
 * would have told every musician on the platform that an admin area exists.
 *
 * The left rule rather than a filled background: a filled amber row beside a
 * red Log out is two shouts in a five item menu, and this is a door rather than
 * a warning. */
.drawer-item--admin {
  color: var(--notice-text);
  box-shadow: inset 3px 0 0 0 var(--notice);
}
.drawer-item--admin:hover { background: var(--notice-surface); }
.drawer-item--admin:focus-visible { outline: 3px solid var(--focus-ring); }

/* The desktop side panel's copy of this lives further down, INSIDE the media
   query that styles .panel-foot-item. Putting it here as well looked right and
   did nothing: that media query is later in the file, so its
   `color: var(--text-muted)` won on source order and the entry rendered grey on
   a laptop and amber on a phone. Measured at 1440 before it was moved. */
.drawer-item svg { width: 20px; height: 20px; fill: var(--action-on-surface); flex-shrink: 0; }

/* AND THE SHIELD ITSELF, AFTER the rule above rather than before it. The same
   mistake as the one described three comments up, made a second time in the
   same file: `.drawer-item--admin svg` and `.drawer-item svg` are both one
   class and one type, so they have identical specificity and the later one
   wins outright. Written above, the shield came out the ordinary violet while
   its label was amber. Measured at 390 and at 1440: fill was
   rgb(207,163,247) when it should have been rgb(239,191,110). */
.drawer-item--admin svg { fill: var(--notice); }

/* ------------------------------------------------------- chrome-less mode --- */

/* Gig Mode: one screen, nothing else. Built in from the start so it doesn't
   have to fight the nav later. */
.app-shell.chromeless .shell-header,
.app-shell.chromeless .shell-nav { display: none; }
.app-shell.chromeless .shell-main { max-width: none; padding: 0; }

/* ------------------------------------------------------------ responsive --- */

/* Two columns once there is room for them. Named views rather than a blanket
   child selector: Gig Mode and the QR page lay themselves out, and turning
   their containers into grids would column them apart. auto-fit rather than
   auto-fill so a view with a single panel still spans the full width instead
   of sitting in a half-width track with a hole beside it. */
@media (min-width: 700px) {
  #profileView, #settingsView, #dashboardView, #songsView {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
    gap: 20px;
    align-items: start;
  }
  #profileView > .panel, #settingsView > .panel,
  #dashboardView > .panel, #songsView > .panel,
  #dashboardView > .dash-trio, #dashboardView > .dash-foot,
  #dashboardView > .welcome-card { margin: 0; }

  /* The chart and the gig list carry the most columns, so they take the whole
     width rather than being squeezed into a half-width track beside something
     short. Same for the song list: it lays itself out in two columns
     internally, and squeezing that into a 330px track would give two columns
     of 150px. */
  #dashboardView > .panel-wide,
  #songsView > .panel-wide { grid-column: 1 / -1; }
}

/* Tablet portrait: keep the bar, widen the content into two columns. */
@media (min-width: 700px) and (orientation: portrait) {
  .shell-main { max-width: 940px; padding-left: 26px; padding-right: 26px; }
}

/* Tablet landscape — the mic-stand case. Vertical space is scarce and the
   bottom edge is furthest from where a hand comes in, so the nav moves to a
   left rail with Go Live at the top. */
@media (orientation: landscape) and (max-height: 820px) and (min-width: 700px) {
  .shell-nav {
    top: 0; bottom: 0; right: auto;
    width: var(--rail-width);
    grid-template-columns: none;
    grid-auto-rows: max-content;
    align-content: start;
    gap: 6px;
    padding: max(14px, var(--safe-top)) 0 calc(14px + var(--safe-bottom));
    border-top: 0;
    border-right: 1px solid var(--border);
  }
  /* ============ ON A PANEL IT IS NOT A CIRCLE ============
   *
   * REPORTED: the circle came out sliced across the top on the side panel,
   * with the label sitting near the top edge of what was left. Measured before
   * the fix: the circle's top edge was 16px above the panel at 1440 and 1920
   * and 20px above the rail at 1024 and at landscape tablet.
   *
   * THE CAUSE was one declaration that is not reset here. The bottom-bar rule
   * carries `margin-top: -34px`, which exists to stop a circle taller than the
   * tab row making the whole bar taller. Both desktop blocks reset `top`,
   * `height` and `margin-bottom`; neither reset the margin. In a bar the
   * margin is absorbed by a grid row sized to its items' margin boxes. In a
   * column flex panel there is no such row: it simply pulls the button 34px
   * up, through the panel's 14px of top padding and 20 past its edge.
   *
   * AND THE FIX IS NOT TO RESET THE MARGIN AND KEEP THE CIRCLE. A protruding
   * disc is a shape that means something on a bar: too large for the bar to
   * contain, half in and half out, with a knockout ring cut through the line
   * it crosses. A panel has no line to cross and nothing to protrude from, so
   * all three of those ideas arrive as decoration and the disc reads as a
   * mobile component dropped into a sidebar. It is also the only item in a
   * column of full-width rows that is neither full width nor a row.
   *
   * IT BECOMES A PRIMARY BUTTON, the panel's own width, the panel's own
   * corner radius, and the tallest thing in the column by half again: still
   * unmistakably the most important control in the panel, and now for the
   * reason the panel is built on rather than in spite of it.
   *
   * SCOPED, DELIBERATELY. Every rule in this block is inside a desktop media
   * query. The bar keeps its circle, its protrusion and its badge exactly as
   * they are, and tests/golive-rises.mjs measures that it did. */
  .nav-golive {
    order: -1;
    height: auto;
    /* THE DECLARATION THAT WAS MISSING. */
    margin-top: 0;
    margin-bottom: 10px;
    top: 0;
    /* THE ONE PLACE IT DOES NOT SIT ON THE COLUMN'S EDGE. The rows here run
       the full 96px of the rail, which costs them nothing because they are
       transparent. A solid accent button run to the same edge would touch the
       rail's own divider down one side, and a filled control resting against a
       border reads as a mistake. Eight pixels off each end. */
    padding: 0 8px;
    align-items: stretch;
  }
  .golive-circle {
    margin-top: 0;
    width: auto;
    /* 76, and the number is set by the badge rather than by the rows. At 64
       the label's line box and the corner badge overlapped by two pixels at
       1024 and at landscape tablet: invisible in a screenshot because the
       overlap fell in the label's leading rather than on a glyph, and one
       theme or one font away from being a collision. There is no horizontal
       escape on an 80px button with a centred label and a corner badge, so
       the clearance has to be vertical, and 76 gives five pixels of it. */
    height: 76px;
    border-radius: var(--radius);
    /* The ring is a hole punched through the bar. There is no bar here. */
    border: 0;
    font-size: 12px;
  }
  /* NO DOT ON THIS ONE. The wide-set LIVE is already most of an 80px button
     and a dot beside it would cost the label the tracking that is doing the
     work. The rail keeps the bar's own arrangement: the word, the setting, and
     the ring blinking. The 232px panel below has the width and takes the dot. */
  /* ============ AND THE BADGE COMES INSIDE ============
   * On the bar it is pinned to the circle's top-right and pushed half out of
   * it, which works because a disc has room outside its own corner and the bar
   * behind it is somewhere to be. A rectangle's corner is the panel's edge:
   * pushed half out there, the badge crosses the rail's divider and loses its
   * right-hand side to the panel bound entirely, which at 96px wide it does.
   * So it sits INSIDE the button on this arrangement, centred on the vertical
   * and set in from the trailing edge, where a full-width control has space it
   * never had as a disc. Knocked out of --action rather than --surface because
   * that is what is behind it now: the ring's job is to keep the badge a
   * separate object, and it can only do that against the colour it is on.
   * THE DESCENDANT SELECTOR IS LOAD-BEARING. The base .golive-badge rule sits
   * further down this file than these blocks do, and a media query adds no
   * specificity at all, so a bare .golive-badge here would lose to it on
   * source order and every one of these declarations would be dead. */
  .shell-nav .golive-badge {
    /* THE CORNER HERE, NOT THE MIDDLE. This rail is 96px wide and the button
       80 of it. A badge set beside a centred label on that measure does not
       sit next to the word, it sits ON it: measured at 1024 and at landscape
       tablet, GO LIVE came out reading "GO LI" and LIVE as "L I". The corner
       is the one place on a button this narrow that the label never reaches.
       Not pushed half out of it the way the bar's is: outside this corner is
       the rail's divider. */
    top: 4px;
    right: 5px;
    left: auto;
    transform: none;
    border-color: var(--action);
  }
  .nav-item { height: 62px; }
  /* The rail runs vertically, so the marker moves to the leading edge. */
  .nav-item::before {
    top: 50%; left: 0;
    transform: translateY(-50%);
    width: 3px; height: 30px;
    border-radius: 0 3px 3px 0;
  }
  /* The rail sits on the leading edge, so it — not the content — is what has
     to clear the notch when the device is turned. */
  .shell-nav { padding-left: var(--safe-left); }
  .shell-header { padding-left: calc(var(--rail-width) + 18px + var(--safe-left)); }
  .shell-main {
    padding-left: calc(var(--rail-width) + 18px + var(--safe-left));
    padding-right: calc(18px + var(--safe-right));
    padding-bottom: calc(28px + var(--safe-bottom));
    max-width: none;
  }
}

/* Desktop: sidebar. Setup work rather than gig work, so it can be denser.
 *
 * ============ OR A FINE POINTER, BECAUSE THE HEIGHT ALONE WAS WRONG ============
 *
 * The three conditions were chosen on 30 August with the reasoning that "a
 * laptop and an 11-inch iPad in landscape are above it" and a tablet on a mic
 * stand is under it. The first half of that is not true, and it was never true.
 *
 * Measured, on this branch, before anything was changed:
 *
 *   1440 x 900   panel        a 15-inch screen with no browser chrome at all
 *   1440 x 800   TWO MENUS    the same laptop with an ordinary browser on it
 *   1440 x 764   TWO MENUS    the same laptop with a bookmarks bar
 *   1512 x 860   panel        a 13-inch MacBook Air
 *   1512 x 820   TWO MENUS    the same Air with a bookmarks bar, one pixel short
 *   1280 x 720   TWO MENUS    a small laptop, or half a screen
 *
 * So the burger and the rail were both on screen for most of the laptops
 * anybody actually owns, which is the two-menu problem that commit was written
 * to remove. Nothing undid it: 821px never included a real laptop window.
 *
 * HEIGHT CANNOT TELL THE TWO APART. The band it was trying to split, roughly
 * 810 to 834 pixels, holds a 10.2-inch iPad in landscape AND a MacBook with a
 * bookmarks bar. No number in there is right for both.
 *
 * A POINTER CAN. The height rule exists to keep a TABLET on the rail and the
 * drawer, because that is gig work and the hand comes in from the edge.
 * `pointer: fine` is the question that actually asks whether this is that
 * device: a mouse or a trackpad is not a hand on a mic stand. An iPad with a
 * Magic Keyboard reports a fine pointer and gets the panel, which is right,
 * because at that point it is being used as a laptop.
 *
 * BOTH BLOCKS CARRY THE SAME LIST, and they have to. The comment on the second
 * one already says a second breakpoint would eventually disagree with the first
 * and produce a window with a sidebar and a burger in it, which is exactly the
 * bug above wearing a different hat. */
@media (min-width: 1100px) and (orientation: landscape) and (min-height: 821px),
       (min-width: 1100px) and (orientation: landscape) and (pointer: fine) {
  .shell-nav {
    top: 0; bottom: 0; right: auto;
    width: var(--sidebar-width);
    grid-template-columns: none;
    grid-auto-rows: max-content;
    align-content: start;
    gap: 4px;
    /* The sidebar runs the full height of the window, so it owns the top AND
       bottom insets on this layout — an iPad Pro in landscape has a home
       indicator along the bottom edge and the sidebar is what sits over it. */
    padding: max(18px, var(--safe-top)) 12px
             calc(18px + var(--safe-bottom)) calc(12px + var(--safe-left));
    border-top: 0;
    border-right: 1px solid var(--border);
  }
  .nav-item {
    flex-direction: row;
    justify-content: flex-start;
    gap: 12px;
    height: 50px;
    padding: 0 14px;
    border-radius: 11px;
    font-size: 14px;
  }
  .nav-item::before {
    top: 50%; left: 0;
    transform: translateY(-50%);
    width: 3px; height: 24px;
    border-radius: 0 3px 3px 0;
  }
  .nav-item.active { background: var(--surface-raised); }
  /* The same argument as the rail above, at the width that has room for it. */
  .nav-golive {
    order: -1;
    height: auto;
    /* THE DECLARATION THAT WAS MISSING. See the rail block above. */
    margin-top: 0;
    margin-bottom: 12px;
    top: 0;
    /* NO PADDING OF ITS OWN. The routes and the foot items both run 12 to 219
       inside a 232px panel, so a button with its own inset would be the one
       thing in the column not on the column. The panel's edge is set once,
       further down, and this follows it rather than restating it. */
    padding: 0;
    align-items: stretch;
  }
  .golive-circle {
    margin-top: 0;
    width: auto;
    /* 56 against the rows' 50. It is filled where they are transparent and it
       heads the column, so the height only has to keep it from looking like
       one of them; making it half again as tall would make it a banner. */
    height: 56px;
    border-radius: var(--radius);
    border: 0;
    font-size: 13px;
  }
  /* The dot, defined once further down. place-items: center centres ONE item;
     a column flow makes room for two, and place-content is what then keeps the
     pair together in the middle. Without it the two implicit columns divide
     the button's full width between them and the dot ends up against one end
     and the word against the other. */
  .nav-golive.is-live .golive-circle {
    grid-auto-flow: column;
    place-content: center;
    gap: 10px;
  }
  .nav-golive.is-live .golive-circle::before { display: block; }
  /* ============ AND THE BADGE COMES INSIDE ============
   * On the bar it is pinned to the circle's top-right and pushed half out of
   * it, which works because a disc has room outside its own corner and the bar
   * behind it is somewhere to be. A rectangle's corner is the panel's edge:
   * pushed half out there, the badge crosses the rail's divider and loses its
   * right-hand side to the panel bound.
   * So it sits INSIDE the button on this arrangement, centred on the vertical
   * and set in from the trailing edge, where a full-width control has space it
   * never had as a disc. Knocked out of --action rather than --surface because
   * that is what is behind it now: the ring's job is to keep the badge a
   * separate object, and it can only do that against the colour it is on.
   * THE DESCENDANT SELECTOR IS LOAD-BEARING. The base .golive-badge rule sits
   * further down this file than these blocks do, and a media query adds no
   * specificity at all, so a bare .golive-badge here would lose to it on
   * source order and every one of these declarations would be dead. */
  .shell-nav .golive-badge {
    top: 50%;
    right: 14px;
    left: auto;
    transform: translateY(-50%);
    border-color: var(--action);
  }
  .shell-header { padding-left: calc(var(--sidebar-width) + 24px + var(--safe-left)); }
  .shell-main {
    padding-left: calc(var(--sidebar-width) + 24px + var(--safe-left));
    padding-right: calc(24px + var(--safe-right));
    padding-bottom: 40px;
    /* The old cap of 1180px INCLUDED the 232px sidebar and its padding, so the
       usable content area was 900px on a 1280 screen and still 900px on a
       1440 one, with the surplus left as a dead strip down the right. The
       shell now fills the window and the CONTENT carries the cap. */
    max-width: none;
    margin-left: 0;
  }
  /* Capped for line length, centred in whatever room is left, and two real
     columns rather than three narrow ones: the dense tables need width more
     than the page needs a third column. */
  #profileView, #settingsView, #dashboardView, #songsView {
    max-width: 1240px;
    margin-inline: auto;
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
  /* EXCEPT THE SONGS PAGE ON A REALLY WIDE SCREEN. 1240 is a line-length cap
     and the right one for prose and for dense tables; the Songs page is
     neither. It is a search, a list of three hundred rows and a set of
     controls, and at 1728 the cap was leaving 256px of dead margin while the
     list paged twenty at a time. Line length there is governed by the 32rem
     cap on the prose itself, not by the width of the page. */
  #songsView { max-width: 1600px; }
  /* ...except Profile, which OWNS its layout: .profile-layout is already
     `minmax(0,1fr) 300px` (form beside the live preview). Handing it a second
     two-column grid made the form a fraction of a fraction — about 250px on a
     1440 screen — and at that width the link URL fields collapsed to ~20px
     empty boxes, the tagline truncated mid-word and every file input read
     "Choose file No fil...osen". A page that already has a column structure
     must not inherit the generic one. */
  #profileView { grid-template-columns: 1fr; }
}

/* Gig Mode controls all outline in --border-strong, not --border.
   A hit target's edge is a control boundary, which WCAG 1.4.11 asks 3:1 of
   and --border does not carry. Mixing the two also inverted the hierarchy on
   Light: the venue input had a near-black outline while the stepper beside it
   had a hairline, and 'End gig' nearly disappeared into the page while 'Skip'
   above it was firmly drawn. light.js allows borders "reserved for things
   that need a hit target" — all of these are hit targets. */

/* ---------------------------------------------------------------- Gig Mode --- */

/* Read from about half a metre on a stand, tapped one-handed. Type is large
   and targets are widely separated; legibility beats density.
 *
 * GIG MODE IS THE SCREEN STAGE EXISTS FOR, so it is the screen that has to
 * actually consume --font-scale and --density. Everything here was hard px
 * before, which meant Stage's promise of "largest type, lowest density" did
 * precisely nothing on the one view a musician looks at mid-song. The two
 * ladders below are the whole mechanism: sizes come off --gm-t-*, vertical
 * rhythm off --gm-space / --gm-card-pad / --gm-control. Nothing branches on
 * theme; Stage simply arrives with bigger multipliers.
 *
 * Declared on :root rather than on .gm because the extend dialog renders as a
 * SIBLING of .gm, not a child of it — scoped to .gm, every size inside that
 * dialog would resolve to an invalid value and silently fall back. */
:root {
  /* Type ramp. Deliberately short — seven steps, not the eleven arbitrary
     values this file used to carry. */
  --gm-t-micro: calc(11px * var(--font-scale));   /* uppercase labels, badges */
  --gm-t-small: calc(13px * var(--font-scale));   /* hints, timestamps, pager */
  --gm-t-body:  calc(14px * var(--font-scale));
  --gm-t-mid:   calc(16px * var(--font-scale));   /* controls, the clock */
  --gm-t-large: calc(19px * var(--font-scale));   /* the numbers that count */
  --gm-t-song:  calc(20px * var(--font-scale));   /* the queue's headline */
  --gm-t-title: calc(30px * var(--font-scale));

  /* Vertical rhythm. One unit, scaled by the theme's density. */
  --gm-space:     calc(12px * var(--density));
  --gm-card-pad:  calc(16px * var(--density));
  --gm-control:   calc(56px * var(--density));    /* primary tap target */
  --gm-exit:      calc(44px * var(--density));
  /* The closed sheet's grab bar. One number, because two things have to agree
     on it: the handle's own height and the bottom padding the queue needs in
     order to scroll clear of it. The breakpoints that move it live down in the
     sheet section, next to everything else the sheet does. */
  --gm-sheet-handle: calc(78px * var(--density));
  /* The ✕ is absolutely positioned; anything sharing its row reserves this. */
  --gm-exit-clear: calc(var(--gm-exit) + 20px);
}

.gm {
  /* THE SIDE GUTTER, NAMED, because the Go Live bar has to cancel it to reach
     both edges of the screen and put it back as padding so the button inside
     still lines up with the fields above. Two places, one number. */
  --gm-gutter: 18px;
  min-height: 100dvh;
  padding: calc(env(safe-area-inset-top, 0px) + var(--gm-space) + 6px)
           var(--gm-gutter)
           calc(env(safe-area-inset-bottom, 0px) + var(--gm-space) + 6px);
  display: flex;
  flex-direction: column;
}

.gm-exit {
  position: absolute;
  top: calc(env(safe-area-inset-top, 0px) + 12px);
  right: 14px;
  background: var(--surface-raised);
  color: var(--text-primary);
  border-radius: 50%;
  width: var(--gm-exit); height: var(--gm-exit);
  font-size: var(--gm-t-large);
  line-height: 1;
}

/* ---- setup ---- */
/* ============ THE HEADER IS ANCHORED, NOT FLOATED ============
 *
 * 22 September 2026, from two reviews measuring the same three states. This
 * rule said `justify-content: center`, so the form was centred in whatever the
 * screen had left and the title landed wherever the account's number of
 * controls put it: 210px down on a 390x844 handset for a Standard account,
 * 22px down for a Pro account with no payments at 375x667, and 14px at
 * 320x568, which is tighter than the 18px gutter the same screen keeps at its
 * sides and sits the title level with the ✕. Three states of one form, three
 * places for its name, and the eye has to find the heading again each time.
 *
 * So the content sits from the top and the title's place is declared: the ✕'s
 * own band, which is 12px down and --gm-exit tall, plus --gm-head-gap. It is
 * the same figure in every state at a given width, because none of it is
 * computed from what is underneath it.
 *
 * THE TWO PLACES THAT CANNOT HAVE IT ARE THE SHORT SCREENS, 320x568 and, as
 * of 22 September, 375x667. The max-height block at the foot of this section
 * takes their anchor back to a few pixels. Measured: in the tallest state the
 * form can reach, a Pro account with no payments and a songbook chosen under
 * "My list only", it needs 573 of the 564 pixels a 320 leaves it and 673 of a
 * 667, so there is nothing spare on either to put the title under the ✕ and
 * still leave the Go Live button reachable. It was 320 alone until that state
 * was measured. The title is anchored on both, at the same offset in all three
 * states, which is the fault above; it simply shares the ✕'s band, cleared by
 * the title's own padding-right. */
.gm-setup {
  position: relative;
  max-width: 520px;
  margin: 0 auto;
  width: 100%;
  justify-content: flex-start;
  padding-top: calc(env(safe-area-inset-top, 0px) + 12px
                    + var(--gm-exit) + var(--gm-head-gap));
  /* ============ IT FILLS THE SCREEN SO THE BUTTON CAN SIT AT THE FOOT ======
   *
   * A sticky element is held inside its own containing block, so a Go Live
   * button pinned to the bottom of the screen needs a parent that reaches the
   * bottom of the screen. Without this the form is exactly as tall as its
   * controls, there is nowhere for the button to be shifted to, and sticky does
   * nothing at all. See .gm-primary below for the whole argument. */
  flex: 1 1 auto;
}
/* ============ THE ✕ ENDS WHERE THE FORM ENDS ============
 *
 * 22 September 2026, measured: the button's right edge was 375.5 on a 390px
 * screen while every field under it stopped at 371.5, and 360.5 against 356.5
 * at 375. The reason was that `right: 14px` is a fixed figure while the form's
 * side gutter is .gm's own padding, so the one control above the fields was
 * the one control not lined up with them. It was four pixels at every width,
 * which is exactly the kind of thing that makes a form look pieced together.
 *
 * 18px is that padding, written out because .gm declares it as a literal too.
 * The button is positioned against .gm-setup's padding box and .gm-setup IS
 * .gm here (one element, both classes), so this is the content's own edge.
 *
 * SCOPED TO THE FORM. The live screen wears .gm-exit as well, and its ✕ sits
 * beside a status bar whose own padding reserves --gm-exit-clear; that screen
 * is not part of this pass and keeps the 14px it has always had.
 *
 * ============ AND IT ANSWERS TO A THUMB AT 44px ============
 *
 * The drawn circle is --gm-exit, which the phone block at the foot of this
 * file takes to 36 so the live screen's top row is 36 tall. Every other
 * control on this form holds 44, and a review measured this one at 36 on the
 * 320px handset. The circle is not grown, because at 320 it is centred on the
 * title's line box and a 44px circle centred there would start two pixels off
 * the top of the screen: the reach is grown instead, the same way .gm-stat-tips
 * and the inline links on this form are given theirs. min() rather than a bare
 * calc so that a --gm-exit of 44 or more leaves the hit area exactly on the
 * circle rather than shrinking it. */
.gm-setup .gm-exit { right: 18px; }
.gm-setup .gm-exit::after {
  content: "";
  position: absolute;
  inset: min(0px, calc((44px - var(--gm-exit)) / -2));
}
/* Display tracking. Deliberately tighter than --tracking-normal because this
   is 30px+ type, where the normal figure reads loose. Kept local and named
   rather than promoted to a token: it is the only element at this size. */
.gm-title {
  font-size: var(--gm-t-title);
  font-weight: var(--weight-strong);
  margin: 0 0 6px;
  letter-spacing: -.03em;
}
/* AND IT CANNOT BE SQUEEZED FLAT, WHATEVER IT ENDS UP INSIDE.
 *
 * 24 September 2026. On 21 September this heading was collapsing to a height of
 * ZERO about a second after the screen opened: .gm-setup-scroll was a flex
 * column, flex items shrink by default, and the moment the venue pills and the
 * songbook list arrived and the content passed the height of the area, the
 * <h1> was the item that gave way. The scroller is an ordinary block now and
 * the cause is gone.
 *
 * These two lines are so that it cannot come back by a different route. A
 * heading with real text in it has a height, and any future parent that would
 * like to argue about that has to say so explicitly rather than by being a
 * flex container somebody added for an unrelated reason. min-height is in ems
 * so it follows the font rather than pinning a pixel count.
 *
 * tests/golive-heading.mjs is the other half: it reads the height five seconds
 * after the screen opens, not at the first frame, because the first frame was
 * never the problem. */
.gm-setup-title {
  flex-shrink: 0;
  min-height: 1em;
}

/* ============ THE PAGE TITLE, AND IT LOOKS LIKE ONE AGAIN ============
 *
 * Mark, 22 September 2026, on his phone: "Start a gig is the page title but it
 * barely reads as one."
 *
 * WHAT IT WAS AND WHY IT WENT. On 21 September this was cut to --gm-t-mid, the
 * size of the field labels under it, to buy back the ninety pixels the old
 * 30px display heading and its two-line subtitle were costing a screen whose
 * primary button then had to be reachable without scrolling. (That rule was
 * replaced on 24 September by a pinned button; the heading's size is a matter
 * of hierarchy now rather than of pixels.) The subtitle went at
 * the same time and has not come back, so the heading is now the only thing in
 * that band, and at label size with label weight it was one more label: the
 * first line of the form rather than the name of it.
 *
 * WHERE THE PIXELS COME FROM NOW. The two Pro switches each gave up about
 * twenty-six by becoming one row instead of a heading above a control, and the
 * two "not set up" notices gave up another sixty between them by becoming
 * sentences with links in them instead of sentences with buttons under them.
 * The title is spending a small part of that, and tests/golive-fits.mjs still
 * holds both handsets.
 *
 * IT IS THE BIGGEST THING ON THE FORM, deliberately and by measurement: the
 * stepper's value and the Go Live button are the only other large type here and
 * both are set below --gm-title-size at every one of the three widths. Nothing
 * else on the page competes with it.
 *
 * SCOPED, BECAUSE .gm-title IS TWO ELEMENTS WEARING ONE CLASS: this screen's
 * heading and, further down this file, the live queue card's song title, which
 * redefines every one of these properties for a single truncating line. That
 * one wins on source order, so each declaration here has to be stated rather
 * than left to inherit. */
.gm-setup .gm-title {
  display: block;
  color: var(--text-primary);
  font-size: var(--gm-title-size);
  font-weight: var(--weight-strong);
  line-height: var(--gm-title-line);
  letter-spacing: -.02em;
  white-space: normal;
  /* The name of the screen is a group of its own, so the first label of the
     form is the group's distance away and not the label's distance. */
  margin: 0 0 var(--gm-group-gap);
  /* Clear of the ✕, which is absolutely positioned in the same band. */
  padding-right: var(--gm-exit-clear);
}
.gm-sub {
  color: var(--text-secondary);
  font-size: var(--gm-t-mid);
  line-height: var(--line-height);
  margin: 0 0 calc(var(--gm-space) * 2);
}
.gm-label {
  display: block;
  font-size: var(--gm-t-body);
  font-weight: var(--weight-medium);
  margin-bottom: 8px;
}
/* Never below 16px in real terms, or iOS zooms the page on focus. */
.gm-input { height: calc(60px * var(--density)); font-size: calc(17px * var(--font-scale)); }
.gm-venue-wrap { position: relative; }
/* ============ THE SUGGESTIONS ARE A DROPDOWN, 24 SEPTEMBER 2026 ============
 *
 * Mark: a compact scrolling dropdown for what is typed, with the town and the
 * postcode in it. It was eight full-width buttons at 14px of padding stacked
 * in the flow, which is about four hundred pixels appearing under the field on
 * a 320px handset: the Private gig switch, the songbook, the length and the Go
 * Live button all went off the bottom of the screen while somebody typed the
 * name of the room they were standing in.
 *
 * SO IT FLOATS AND IT PUSHES NOTHING. Absolute, at the bottom of
 * .gm-venue-wrap, which contains the field, the optional town and the hint
 * line: see the note in js/views/gigmode.js for why the hint is inside it and
 * this is after it. Four rows tall and then it scrolls, which is as many as
 * anybody reads before they type another letter.
 *
 * AND THE ROWS ARE HALVED. One box with one border round the whole list and a
 * hairline between the rows, rather than eight bordered cards with gaps: the
 * name at the size it was, the town and the postcode under it. 44px is still
 * the floor on every row, because this is a form used in a doorway. */
.gm-venue-list {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  z-index: 6;
  display: grid;
  gap: 0;
  max-height: calc(var(--gm-row) * 4);
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  background: var(--surface-raised);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  /* IT SITS ON TOP OF THE FORM, so it needs to look as though it does. Without
     this the songbook heading reads straight through the gap between rows. */
  box-shadow: 0 8px 24px rgba(0, 0, 0, .28);
}
/* AN EMPTY LIST IS NOTHING AT ALL, not an empty bordered box floating under
   the field, which is what it is for all but a few seconds of the form's
   life. */
.gm-venue-list:empty { display: none; }
.gm-venue-option {
  text-align: left;
  padding: 8px 12px;
  min-height: 44px;
  border-radius: 0;
  background: none;
  border: 0;
  /* THE HAIRLINE IS BETWEEN THE ROWS AND NOT ROUND THEM. :first-child would
     have done it the other way and left a line under the last row against the
     box's own border. */
  border-top: 1px solid var(--border);
  color: var(--text-primary);
  font-size: var(--gm-t-mid);
  font-weight: var(--weight-medium);
}
.gm-venue-option:first-child { border-top: 0; }
/* A HINT BELONGS TO THE CONTROL ABOVE IT, so it keeps the small gap and the
   section it is in keeps the large one. The bottom margin was
   calc(var(--gm-space) * 1.5), which put a second gap under the hint on top of
   the section's own and made the space above a heading and the space below its
   control read the same. See --gm-label-gap and --gm-group-gap below. */
.gm-hint {
  font-size: var(--gm-t-small);
  color: var(--text-secondary);
  margin: var(--gm-label-gap) 0 0;
  min-height: 18px;
}
/* AN EMPTY HINT RESERVES ITS LINE FOR NOTHING, and on the smallest handset
   there are two of them empty at once — the venue's and the book's — which is
   about fifty pixels of blank space in the exact place the screen has none to
   spare. The line is reserved so the form does not jump when a hint appears,
   and that reasoning holds for the book (the select is the only thing above
   it, and it does not move); it never held for the venue, whose suggestion
   list appears in flow directly above and shifts everything below it by far
   more than eighteen pixels either way.
   Kept as a reserved line where the jump would be visible, dropped where it
   would not: the venue's, only while it is empty. */
/* ============ AND THE BOOK'S GOES THE SAME WAY, 21 SEPTEMBER 2026 ==========
 *
 * The reasoning above held while the select sat directly over the hint. The
 * request-mode row was put between them a week later, and it is what the hint
 * would have jumped past. What moves when a book is chosen is everything BELOW
 * the hint, and the musician has at that moment just operated the select: they
 * are looking at the thing that moved, not at the Go Live button.
 *
 * Against that: forty-four pixels of nothing, permanently, on the default
 * screen of the form that has to fit a 320x568 handset with its button whole.
 * That is the trade, and the fold is worth more than the jump. */
/* BOTH EMPTY HINTS COLLAPSE ENTIRELY NOW, margin included: the gap below each
   of them is its section's own bottom margin (.gm-sec), and two margins for one
   gap is how a form ends up with a hole in the middle of it. */
#gmVenueHint:empty,
#gmBookHint:empty { min-height: 0; margin: 0; }

/* ---- what people can request, above the book's hint ----
 *
 * Quiet by instruction: "It does not need to be prominent." So it is a label
 * at hint size and a segmented control at hint size, not a labelled field like
 * the venue and the book above it. It is a line of confirmation before a
 * decision, and it must not read as another thing to fill in.
 *
 * The same shape as the Songs tab's control (css/dashboard.css,
 * .songs-mode-seg): a sunken track, both options always visible, the chosen
 * one filled rather than tinted. One setting should not have two appearances
 * any more than it should have two vocabularies. Sized off Gig Mode's own
 * ladders, so Stage's larger type and looser density reach it like everything
 * else on this screen.
 *
 * The track carries --border-strong rather than --border, per the rule at the
 * top of this section: it is the visible boundary of a hit target, which WCAG
 * 1.4.11 asks 3:1 of.
 *
 * THE ROW IS A BLOCK, AND THE SEGMENT FILLS IT. This was a wrapping flex row:
 * the label sat beside the control from about 440px up and above it below that,
 * and the control was an inline-flex sized to its own two labels, so it stopped
 * short of the right edge that every field on the form shares. Both of those
 * were the same fault Mark named, that the page is "misaligned, inconsistent".
 * A heading above a full-width control is what the venue, the songbook, the two
 * switches and the stepper all do, so the mode does it too, at every width.
 *
 * THE BUTTON LABELS STILL WRAP. "Only what's on my list" is a long label, and
 * at Stage's font scale a segment that refused to wrap would push a control off
 * the side of a 375px screen. Two lines inside a button is survivable; a
 * control you cannot reach is not. */
/* A SECOND GROUP INSIDE THE SONGS SECTION, and it is spaced like one. The top
   margin was a flat 10px, which is about what the songbook's own label was
   given above it, so "What people can request" floated between the picker above
   and the segment below with nothing saying which it named. */
.gm-mode-row {
  display: block;
  margin: var(--gm-group-gap) 0 0;
}
/* ============ THE LABEL ON ITS OWN LINE IS NOW THE RULE, NOT THE FALLBACK ===
 *
 * What was here said the opposite: the label was 143px and the control 259px,
 * which is 402px of a 339px content width, so a handset wrapped them and
 * anything from about 440px up put them side by side. That was written when
 * this control was meant to be quiet, a line of confirmation rather than a
 * field. It is a .gm-h like every other heading now, and a heading that sits
 * above its control on a phone and beside it on a tablet is two layouts for one
 * row. It sits above, always.
 *
 * .gm-mode-label IS KEPT BELOW because the Room controls panel still uses it
 * for the locked charging block and the live tips block mid-gig. Nothing on
 * this form wears it any more. */
.gm-mode-label {
  font-size: var(--gm-t-small);
  color: var(--text-secondary);
}
.gm-mode-seg {
  display: inline-flex;
  flex: 0 0 auto;
  max-width: 100%;
  padding: 3px;
  gap: 3px;
  background: var(--surface-sunken);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
}
.gm-mode-seg button {
  min-height: calc(38px * var(--density));
  min-width: 0;
  padding: 6px 12px;
  border: 1px solid transparent;
  border-radius: calc(var(--radius) - 3px);
  background: transparent;
  color: var(--text-secondary);
  font-size: var(--gm-t-small);
  font-weight: var(--weight-medium);
  line-height: 1.25;
  text-align: center;
}
/* aria-pressed carries the state to a screen reader; this is its visual twin.
   Filled, not tinted: this screen is read at half a metre under coloured
   lighting, where a tint this small is not there at all. */
.gm-mode-seg button[aria-pressed="true"] {
  background: var(--action);
  border-color: var(--action);
  color: var(--action-text);
}

/* The free limit in the Room controls while this gig is charging: dimmed, not
   hidden, and the hint under it says why. */
.gm-room-block.is-paused .gm-limit-seg { opacity: 0.5; }
.gm-room-block.is-paused .gm-limit-seg button { cursor: not-allowed; }

/* ---- free or paid requests: two options, each a name and a line ----
 *
 * The same control as .gm-mode-seg in every way that shows: the sunken track,
 * the filled pressed state, the same radius. What differs is that each option
 * carries a line under its name, so they sit side by side as equal columns and
 * take the full width, and at 320px each is still about 140px, which holds
 * "Anyone can send a few, then they wait." on three short lines.
 *
 * The label carries the Pro pill: this control exists only for a Pro account
 * with paid requests on. A word and a solid pill, never a coloured status. */
/* TWO NAMES, ONE RULE. .gm-tips-row is the per-gig Take tips control and
   .gm-charging-row is the Free or paid one; they sit side by side and are the
   same object. They are not ONE name because .gm-charging-row .pro-pill is the
   selector paid-requests-browser uses to find the charging control's Pro mark,
   and a second element answering to it broke that suite. */
.gm-charging-row,
.gm-tips-row { margin: 10px 0 0; }
.gm-charging-row .gm-mode-label,
.gm-tips-row .gm-mode-label { display: inline-flex; margin-bottom: 6px; }
/* THE LOCKED VERSION IN THE ROOM PANEL. The live one is a <section
   class="gm-room-block"> with the label and the segment as its direct children;
   the locked one wraps those two in a block of its own, because setProLock()
   dims and disables a block's contents and the hint under it is not part of the
   control. Same label spacing as the setup screen's row, so the two read the
   same. */
.gm-room-charging-locked .gm-mode-label { display: inline-flex; margin-bottom: 6px; }
/* The live tips control in the Room panel, same label spacing as the charging
   block above it so the two read as one pair. */
.gm-room-tips .gm-room-label { display: inline-flex; margin-bottom: 6px; }
.gm-charging-seg {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 3px;
  padding: 3px;
  background: var(--surface-sunken);
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
}
.gm-charging-seg button {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  min-height: calc(44px * var(--density));
  min-width: 0;
  padding: 8px 10px;
  border: 1px solid transparent;
  border-radius: calc(var(--radius) - 3px);
  background: transparent;
  color: var(--text-secondary);
  text-align: left;
}
.gm-charging-name { font-size: var(--gm-t-small); font-weight: var(--weight-strong); }
.gm-charging-line { font-size: var(--gm-t-small); line-height: 1.3; }
.gm-charging-seg button[aria-pressed="true"] {
  background: var(--action);
  border-color: var(--action);
  color: var(--action-text);
}

/* ---- the same segmented control with three or four choices in it ----
 *
 * .gm-mode-seg holds two and never has to wrap. The request limit holds three,
 * or four when the musician's own setting is not one of the three, and at
 * 320px four will not sit on one line: without the wrap the last button is cut
 * off by the panel's own overflow and cannot be tapped at all.
 *
 * FULL WIDTH RATHER THAN inline-flex, so the buttons divide the row evenly
 * instead of sizing to their words. "Balanced" and "Tight" are different
 * lengths and a row of different-sized targets on a screen read at half a
 * metre is a row where the small one gets missed.
 *
 * THE NUMBER IS UNDER THE NAME, the same arrangement the profile page's cards
 * use. "Tight" is a shorthand; the setting is the figure, and a musician
 * deciding mid-song is deciding between 2 and 8. */
.gm-limit-seg {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(4.5rem, 1fr));
  width: 100%;
}
/* A GRID RATHER THAN A WRAPPING FLEX ROW, and the difference shows only when
   there are four choices on a 320px screen. Wrapped flex items stretch to fill
   whatever row they land on, so the fourth button became a full-width bar
   under three normal ones: the odd one out by size, which reads as a different
   kind of control rather than as the fourth of four. In a grid it keeps the
   column width and sits under the first. */
.gm-limit-seg button {
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
}
.gm-limit-n {
  font-style: normal;
  font-size: var(--gm-t-small);
  font-variant-numeric: tabular-nums;
  opacity: .75;
}
.gm-mode-seg button[aria-pressed="true"] .gm-limit-n { opacity: 1; }

/* ============ THE GO LIVE FORM, AS ONE PAGE ============
 *
 * Mark, 22 September 2026, holding his phone: the form "looks unprofessional,
 * misaligned, inconsistent, and the labels do not read clearly". Everything
 * from here to the end of the narrow-screen blocks is the answer to that, and
 * it is one system rather than six fixes.
 *
 * ONE LEFT EDGE AND ONE RIGHT EDGE. Every heading, every control and every hint
 * is the full width of the form and starts at the same x. Nothing is sized to
 * its own words: the request-mode segment used to be, which is why it stopped
 * short of the right margin while the two fields above it did not.
 *
 * ONE SECTION. .gm-sec is a heading and its control, with the gap between
 * sections declared once, here, rather than by whichever element happened to
 * sit above. That is what makes the rhythm survive an account that has two more
 * controls than another.
 *
 * ONE HEADING. .gm-h, at one size and weight, whether it labels a text field, a
 * picker, a switch or the stepper.
 *
 * ONE ROW PER ON/OFF ANSWER. .gm-switch is the heading, the track and the word
 * on a single line reading "Paid requests ....... [  ] On". See
 * setupSwitchMarkup in js/views/gigmode.js for the orphaned headings it
 * replaced.
 *
 * ONE HEIGHT FOR EVERYTHING A THUMB LANDS ON. --gm-row is the height of the
 * venue field, the songbook picker, both switch rows and all three cells of the
 * stepper. Before this they were 60, 60, 44, 44 and 74, which is five heights
 * for five things that are all the same kind of thing. It never goes below the
 * 44px floor at any of the three widths.
 *
 * THE ONE DELIBERATE EXCEPTION is Go Live itself, which is taller than a row
 * because it is the button the screen exists for. */

/* THE FORM'S OWN MEASUREMENTS, IN ONE PLACE, so the three widths this screen
   has to survive are three short lists at the foot of this section rather than
   a rule per element. --density and --font-scale still reach every one of them,
   which is how Stage arrives bigger here as it does everywhere else. */
.gm-setup {
  --gm-title-size: calc(30px * var(--font-scale));
  --gm-row:        calc(58px * var(--density));
  --gm-face:       calc(22px * var(--font-scale));
  --gm-go:         calc(68px * var(--density));

  /* ============ WHAT THE MUSICIAN IS CHOOSING, AT ONE SIZE ============
   *
   * Mark, 23 September 2026, reading the form on his phone: the words that ARE
   * the answer have to read like the answers in the fields do.
   *
   * THE FORM HAS TWO KINDS OF WORD ON IT AND THEY WERE SET AS ONE. A caption
   * names a control: Venue, My song list, What the audience can request, Gig
   * length. A CHOICE is the answer itself, and four of them were the same size
   * as the captions above them or smaller. Measured on a phone, where the ramp
   * at the foot of this file is in force: "Paid requests" and "Tips" beside
   * their switches at 13px, "Anything" and "My list only" inside the segment at
   * 12, against "All songs" sitting in the picker at 17 and "2 hr" in the
   * stepper at 19. A musician scanning the form for what it is set to was
   * reading the smallest type on the page to find out.
   *
   * SO THE ANSWERS GO TO THE FIELD'S OWN SIZE and the captions do not move at
   * all. That is the whole of the change: the caption stays --gm-t-body with
   * --weight-medium so it does not compete, and the answer reads at the size
   * the answer in the box beside it reads at.
   *
   * ONE TOKEN, NOT FOUR LITERALS, for the reason every other figure on this
   * form is a token: the three widths this screen survives are three short
   * lists, and a size scattered over four rules is a size that moves in three
   * of them the next time somebody measures this page.
   *
   * IT IS THE SAME FIGURE AT ALL THREE WIDTHS, and it is declared once rather
   * than three times, because the thing it follows does not move either:
   * .gm-input is calc(17px * var(--font-scale)) at 320, 375 and 390 alike, and
   * has been since it was written. --gm-face, the stepper's figure, is the one
   * that steps down with the screen, and it is deliberately NOT what this
   * tracks: the length is a single number read across a room and is allowed to
   * be the biggest thing on the form after the title. If .gm-input ever comes
   * down at a width, this token comes down with it, in that width's block. */
  --gm-t-choice:   calc(17px * var(--font-scale));

  /* ============ TWO GAPS, AND THEY ARE NEVER THE SAME ONE ============
   *
   * 22 September 2026, and this is the fault both reviews put first. Every
   * label on the form was 8px under its own control and 9px under the control
   * above it, measured 10.5px and 10px on a 390px phone once the line box was
   * counted. A label equidistant between two controls names neither of them:
   * the eye cannot tell whether "Songs" is the last word about the venue field
   * or the first word about the picker underneath, and it has to read ahead to
   * find out. Every label on the form was doing that.
   *
   * So there are two figures here and the whole form is built from them.
   * --gm-label-gap binds a label to the control it names and nothing else.
   * --gm-group-gap separates one finished group from the next label. The ratio
   * is what does the work rather than either number, and it holds at all three
   * widths: roughly one to three.
   *
   * --gm-pair-gap is the third, and it exists for one relationship: the paid
   * requests row and the tips row are peers, two answers to one question about
   * money, so they sit closer to each other than either sits to the groups
   * above and below. Measured before this, they had the LARGEST gap on the
   * page between them, 27.5px against 17.5 and 19 either side, which is the
   * opposite of what they are.
   *
   * --gm-head-gap is the air under the ✕'s band before the title starts, on
   * the screens tall enough for the title to have a line of its own. The two
   * short ones share the band and never read it: see the max-height block. */
  --gm-label-gap:  6px;
  --gm-group-gap:  22px;
  --gm-pair-gap:   10px;
  --gm-head-gap:   12px;

  /* ============ A NOTE IS BOUND TO THE ROW ABOVE IT, NOT TO THE NEXT ========
   *
   * 22 September 2026, from a review that measured the tips note on all three
   * widths: 13px below its own row and 11.5px above the next section's label
   * at 320, and 15 against 16.5 at 375. A sentence equidistant between the row
   * it explains and the heading it does not is the same fault --gm-label-gap
   * and --gm-group-gap were introduced to fix one level up, and it needs its
   * own two figures because a switch row is not a label.
   *
   * --gm-note-gap is the gap above a note, and it is SMALLER than the label
   * gap on purpose. A switch row is 44px tall around a 30px track, so seven
   * pixels of the row's own air already sit between the track and the row's
   * bottom edge and are read as part of the gap under it. Six pixels of margin
   * under a 44px row buys thirteen of visible space; under a label it buys six.
   *
   * --gm-note-after is the space under the LAST note in the money slot, on top
   * of the slot's own group gap, so the next section's heading is further away
   * than the note is from its row at every width.
   *
   * IT WAS ZERO ON A TALL SCREEN, on the argument that a group gap of 22 does
   * the job on its own. It does not, and the reason is that a gap is read from
   * what can be seen rather than from the box: every other section on this
   * form ends with a 50px control around a 30px track, so ten pixels of the
   * control's own air are already part of the space under it, and a note is a
   * line of text with none. Twenty-two after a note reads as about ten less
   * than twenty-two after a row, which is why the money slot ended tighter
   * than any other section on the 390px handset. Eight puts it level, and the
   * tall-handset block below is where that eight is set. The two short screens
   * have always set their own. */
  --gm-note-gap:   var(--gm-label-gap);
  --gm-note-after: 0px;

  /* ============ AND THE NOTE'S OWN BOTTOM GAP IS A THIRD FIGURE ============
   *
   * 22 September 2026, from two reviews measuring the 390px handset. The
   * payments note's bottom margin was written out here as
   * calc(var(--gm-group-gap) - var(--gm-pair-gap)), which was deliberate: the
   * note and the gap under it then always added up to exactly one group gap,
   * so moving the boundary inside that sum changed no heights. The cost of it
   * is that whenever a note sits between the two money rows, the space between
   * those rows is a GROUP's worth however small the pair gap is set. Measured
   * at 390: 33.5px between the payments note and the Tips row, against 25px at
   * the end of the whole section. The biggest void on the page fell between
   * two rows that are one answer about money, and Tips read as a new section.
   *
   * Naming it breaks that sum. The default IS the old figure, so nothing moves
   * at 320 or 375, where both reviews measured the order as already right; the
   * tall-handset block below sets its own. */
  --gm-note-pair:  calc(var(--gm-group-gap) - var(--gm-pair-gap));

  /* The title's leading, named rather than written into the one rule that used
     it, because the ✕ is centred on the title's line box at 320px and the calc
     that does the centring has to know this number. See the 320px block. */
  --gm-title-line: 1.12;
}

.gm-sec { margin: 0 0 var(--gm-group-gap); }
.gm-h {
  display: block;
  font-size: var(--gm-t-body);
  font-weight: var(--weight-medium);
  /* ITS OWN LINE HEIGHT, NOT THE PAGE'S. These are one-line labels of one or
     two words, and the document's 1.5 was putting three or four pixels of
     nothing above and below each of them. On a form with five headings that is
     a loose, unset rhythm on the screen with the least room to spare. */
  /* 1.25 rather than 1.3, because the leading is read as part of the gap under
     the label: at 13px that half-line is close to two pixels, and two pixels
     matter when the binding gap itself is six. */
  line-height: 1.25;
  color: var(--text-primary);
  /* THE LABEL IS BOUND TO ITS OWN CONTROL. This was a flat 8px against a 9px
     section gap above, which is the equidistant label both reviews named. */
  margin: 0 0 var(--gm-label-gap);
}
/* An old label's suggestion, inside the venue card. Quiet: it is an offer, not
   a task, and it sits under the venue's own name rather than above it. */
.vm-label {
  margin: 10px 0 0;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius, 10px);
  background: var(--surface-raised, var(--surface));
}
.vm-label-say {
  margin: 0 0 8px;
  font-size: .875rem;
  line-height: 1.45;
  color: var(--text-secondary);
}
.vm-label-row { display: flex; flex-wrap: wrap; gap: 8px; }
.vm-label-row button { min-height: 40px; }

/* The line under the town box, when a typed name cannot be shared. Quiet, and
   it takes no height when there is nothing to say: [hidden] is display:none. */
.gm-newtown-say {
  margin: 6px 0 0;
  font-size: var(--gm-t-small, .8125rem);
  line-height: 1.4;
  color: var(--text-secondary);
}
.gm-newtown-say[hidden] { display: none; }

/* What Check again just did. One line, gone after a few seconds, and it takes
   no height when there is nothing to say. */
/* ============ WHAT THE DOT SAYS, AND THE FIGURES BEHIND IT ============
 *
 * 27 September 2026. Two or three words for the colour, then how far and how
 * accurate, both on the row the dot already had. The Go Live form must fit
 * whole on a 375x667 phone, so neither of these may add a row of its own.
 *
 * THE LABEL IS READ, THE FIGURES ARE LOOKED UP. Same size, the label in the
 * text colour and the figures a step back, which is the difference between
 * something read at arm's length between songs and something somebody leans in
 * for. Both wrap with the line rather than pushing it sideways. */
/* ============ THE READING IS WORKING, AND THEN IT HAS FINISHED ============
 *
 * Mark, 28 September 2026: "Check again does not look like it is doing
 * anything", and the settle that happens when the screen opens had the same
 * problem. Three states, all in the row the reading already occupies, because
 * the Go Live form must fit whole on a 375x667 phone.
 *
 * THE PULSE IS A SECOND SIGNAL AND NEVER THE ONLY ONE. The word beside it says
 * the same thing, so somebody who has turned motion off loses nothing. */
@keyframes gm-loc-working {
  0%   { box-shadow: 0 0 0 0 currentColor; opacity: 1; }
  70%  { box-shadow: 0 0 0 7px transparent; opacity: .55; }
  100% { box-shadow: 0 0 0 0 transparent; opacity: 1; }
}
.gm-loc.is-settling { animation: gm-loc-working 1.4s ease-out infinite; }
@media (prefers-reduced-motion: reduce) {
  .gm-loc.is-settling { animation: none; }
}
/* The finished word stands out from the figures beside it, which is the whole
   point: "give or take 8m" and "settled" must not read as one grey run. */
.gm-loc-settled { color: var(--positive-text, #1a7f4b); font-weight: 600; }

.gm-loc-said { font-weight: 600; }
.gm-loc-figs,
.gm-loc-where { color: var(--text-secondary); }
/* EVERY PAIR, and the settling word is a pair too: it carries the figures'
   class for its colour and was running straight into "12m" without one. */
.gm-loc-said + .gm-loc-figs::before,
.gm-loc-figs + .gm-loc-figs::before,
.gm-loc-figs + .gm-loc-where::before,
.gm-loc-said + .gm-loc-where::before { content: '\00b7'; margin: 0 .4em; }

.gm-recheck-said {
  margin: 4px 0 0;
  font-size: var(--gm-t-small, .8125rem);
  line-height: 1.35;
  color: var(--text-secondary);
}
.gm-recheck-said[hidden] { display: none; }

/* The dot is a button now, so it needs a target a thumb can find and a focus
   ring. The visible dot is the same size it always was; the padding that makes
   it tappable is negative margin, so it takes no height from the line it sits
   in. */
button.gm-loc {
  border: 0;
  cursor: pointer;
  /* content-box, AND IT IS THE POINT. .gm-loc is 9px square and everything in
     this app is border-box, so padding on it ate inwards and left a 9px target
     with a 9px dot squeezed inside it. Sized from the content out instead:
     9px of dot plus 14px each side is a 37px target, which is what the Check
     again control beside it is and what a thumb can find.
     background-clip keeps the COLOUR on the 9px dot, so nothing looks bigger;
     the negative margin gives the height back to the line it sits in. */
  box-sizing: content-box;
  padding: 14px;
  margin: -14px;
  background-clip: content-box;
}
button.gm-loc:focus-visible { outline: 2px solid var(--action); outline-offset: 1px; }

/* The key's sheet scrolls itself if it ever outgrows the screen (a phone on
   its side), rather than running off the top where nothing can reach it. */
.gm-sheet.gm-dotkey {
  max-height: calc(100vh - 24px);
  max-height: calc(100dvh - 24px);
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
}

/* The key itself, in the sheet slot the setup screen already has. */
.gm-dotkey-list {
  list-style: none;
  margin: 0 0 10px;
  padding: 0;
  display: grid;
  gap: 10px;
}
.gm-dotkey-list li {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 10px;
  align-items: start;
  font-size: var(--gm-t-small, .8125rem);
  line-height: 1.4;
  color: var(--text-secondary);
}
.gm-dotkey-list .gm-loc { margin-top: 4px; }
.gm-dotkey-foot {
  margin: 0 0 12px;
  font-size: var(--gm-t-small, .8125rem);
  line-height: 1.4;
  color: var(--text-secondary);
}

/* ============ A LABEL WITH A CONTROL ON THE END OF ITS ROW ============
 *
 * 25 September 2026, for "Check again" beside Venue. The rule this form is
 * held to is no new row and no new height, so the control shares the label's
 * line rather than taking one of its own.
 *
 * THE LABEL KEEPS ITS OWN BOTTOM MARGIN AND THE ROW HAS NONE. .gm-h binds
 * itself to the control underneath with --gm-label-gap, and every other label
 * on this form does the same; wrapping it in a flex row and giving the ROW the
 * margin instead would have been two rules to keep in step for no gain.
 *
 * align-items: baseline, so the word "Venue" and the word "Check again" sit on
 * one line however the two type sizes are set. */
.gm-h-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  min-width: 0;
}
.gm-h-row > .gm-h { flex: 0 1 auto; min-width: 0; }

/* SMALL, QUIET, AND STILL A 44px TARGET. The visible control is the size of
 * the label beside it; the padding that makes it thumb-sized is negative
 * margin on the outside, so it takes no height from the row. A control this
 * form cannot afford to make bigger still has to be tappable by somebody
 * holding a guitar. */
.gm-recheck {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  min-height: 0;
  padding: 11px 8px;
  /* NO NEGATIVE MARGIN ON THE RIGHT. It was -8px, to sit the words flush with
     the fields below, and it put the control 8 pixels past the scroller's edge
     at every width: measured as 300 against 292 at 320px, which is the page
     moving sideways. The tap target is still 44 pixels wide, it just no longer
     hangs over the edge to get there. */
  margin: -11px 0;
  border: 0;
  background: none;
  font-size: var(--gm-t-small, .8125rem);
  line-height: 1.2;
  color: var(--text-secondary);
  border-radius: 8px;
}
.gm-recheck:hover:not(:disabled) { color: var(--text-primary); }
.gm-recheck:disabled { opacity: .6; }
.gm-recheck-icon {
  width: 14px;
  height: 14px;
  display: block;
  flex: 0 0 auto;
}
/* IT TURNS WHILE IT IS WORKING, which is the only thing on the screen saying
   a reading is being taken. Stopped for anybody who has asked for less
   movement: the word changes to "Checking..." either way, so nothing is
   carried by the spin alone. */
.gm-recheck:disabled .gm-recheck-icon { animation: gm-recheck-spin 1.1s linear infinite; }
@keyframes gm-recheck-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .gm-recheck:disabled .gm-recheck-icon { animation: none; }
}

/* The Pro pill sits after the words and never pushes them. */
.gm-h.pro-heading { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }

/* The two fields join the rhythm rather than keeping their own height. Never
   below 16px of type in real terms, or iOS zooms the page on focus. */
.gm-setup .gm-input { height: var(--gm-row); }

/* THE REQUEST-MODE SEGMENT FILLS THE ROW, in two equal columns, so its edges
   are the form's edges and its two targets are the same size as each other. It
   was an inline-flex sized to its labels, which made "Anything" a third of the
   width of "Only what's on my list" and left the pair floating short of the
   right margin. Scoped to .gm-setup because .gm-mode-seg is also the Room
   panel's request-limit control mid-gig, which has three or four choices in it
   and sizes itself from .gm-limit-seg. */
.gm-setup .gm-mode-seg {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  width: 100%;
}
/* SIX AND SIX, NOT SIX AND EIGHT, and it is measured rather than tidied. Two
   equal columns of a 320px form are 140px each, and "Only what's on my list" at
   the phone ramp's 12px needs about 128 of them. At eight either side it wrapped
   to a second line and the segment stood 51px tall instead of 44, on the one
   screen where seven pixels matter. */
.gm-setup .gm-mode-seg button { padding: 6px 6px; }
/* ============ AND THE TWO OPTIONS ARE ANSWERS, NOT LABELS ============
   23 September 2026. These were --gm-t-small, which the phone ramp at the foot
   of this file takes to 12px: the two words saying what the audience may ask
   for were the smallest type on the form, a pixel under the caption above them
   and five under the picker's own "All songs" right beside them. They are what
   the musician is CHOOSING, so they read at the size that answer reads at. See
   --gm-t-choice. Scoped to .gm-setup, like the rules around it, so the Room
   panel's request-limit segment keeps the size it has mid-gig.

   ============ AND THE WEIGHT, NOT ONLY THE SIZE, 23 SEPTEMBER 2026 =========

   The first pass matched the size and left the weight at --weight-medium,
   which a reviewer caught at 3x zoom: "Anything" sat visibly heavier than "All
   songs" in the picker directly above it, so the two still did not read as the
   same kind of word. Mark's instruction was the same size AND weight as the
   text inside the fields, and a <select> renders its own text at the UA
   default, which is --weight-normal.

   THE PRESSED OPTION GOES TO 400 AS WELL, and that was measured rather than
   assumed: its ink is --action-text on the filled --action pill, which is
   5.73:1 in Dark and 8.71:1 in Light. Weight does not move a contrast ratio,
   and at 17px on a pill clearing 5.7:1 there is no legibility argument for
   keeping this one heavier, so it is not kept heavier and the segment reads as
   one control in both states. */
.gm-setup .gm-mode-seg button {
  font-size: var(--gm-t-choice);
  font-weight: var(--weight-normal);
}
/* ============ AND IT IS AS TALL AS EVERY OTHER CONTROL, 22 SEPTEMBER =======
 *
 * Two reviews measured the same thing on a 390px handset: the venue field, the
 * song list, the stepper's cells and the switch rows were all --gm-row, which
 * is 50 there, and this segment was 44. Four boxes of identical width stacked
 * down one grid with one of them twelve per cent shorter reads as squashed,
 * and it was wrong at that width alone: at 375 and 320 --gm-row IS 44, which
 * is what the segment happened to be.
 *
 * HAPPENED TO BE IS THE FAULT. The button's min-height was a flat
 * calc(36px * var(--density)) in the 430px block, a figure that answered to
 * nothing else on the form. It is the row height less the track's own inset
 * now, so the drawn track is exactly --gm-row at all three widths and the 36
 * at 375 and 320 falls out of that sum rather than being stated separately.
 *
 * THE INSET IS 8px: the track's 3px of padding above and below the pill and
 * its 1px border. The pill goes from 36 to 42 at 390 and the track from 44 to
 * 50; nothing moves on the two short screens.
 *
 * SCOPED TO .gm-setup, so the Room panel's request-limit segment, which wears
 * .gm-mode-seg too and has no --gm-row to answer to, keeps the flat figure the
 * 430px block gives it. */
.gm-setup .gm-mode-seg button { min-height: calc(var(--gm-row) - 8px); }

/* ============ THE SWITCH ROW ============
 *
 * The heading, then the track, then the answer in words, on one line. The
 * heading takes whatever is left, which is what lines the track and the word up
 * with each other down the right of the form however long the two labels are.
 *
 * THE WORD IS AT THE SIZE OF THE HEADING, not at hint size: this is the line a
 * musician reads at arm's length in a dark room. It is right-aligned inside a
 * fixed minimum width so that On and Off, which are different widths, cannot
 * move the track as the switch is used.
 *
 * min-height RATHER THAN height, because the heading is allowed to wrap on to a
 * second line at the narrowest width with a long label and a Pro pill, and a
 * fixed height would put the second line outside the row. */

/* ============ WHAT THE 22 SEPTEMBER REBUILD TOOK AWAY FROM THIS BLOCK ===
 *
 * Six rules have gone, and none of them is a loss. The Private gig switch
 * used to be a small toggle sharing the Venue label's row, drawn with its
 * own .gm-private-switch, .gm-private-text and a .gm-venue-head flex row,
 * because the old form had no room for a section of its own. The rebuilt
 * form does: it is a .gm-sec drawn by setupSwitchMarkup(), the same shape,
 * the same heading style and the same On/Off word as the two money
 * controls, which is exactly the "one heading style, matching toggles"
 * Mark asked for. So .gm-venue-head, .gm-private-slot, .gm-private-switch,
 * .gm-private-text, .gm-event-slot and .gm-event-label are all gone and
 * the switch is styled by main's .gm-switch above.
 *
 * What is left below is the part that is genuinely the venue picker's: the
 * nearby buttons, the suggestion rows' second line, the optional town, the
 * event type that rides inside the switch's section, the duplicate
 * question, and the two quiet links in the hint line. */
/* WHAT IS NEAR. A row of one-tap buttons that wraps, above the field rather
   than below it, because it is the answer for most gigs and the field is the
   fallback. Nothing here is ever selected: two venues can be fifteen metres
   apart and the phone cannot tell them apart, so this is an order. */
.gm-nearby-slot:empty { display: none; }
.gm-nearby { margin: 8px 0 10px; }
.gm-nearby-title {
  display: block;
  font-size: var(--gm-t-small);
  color: var(--text-secondary);
  margin-bottom: 6px;
}
/* ============ AND WHAT IS NEAR IS PILLS, 24 SEPTEMBER 2026 ============
 *
 * Mark: nearby venues are pills, at most four, and a pill says the name.
 *
 * A PILL IS SIZED BY ITS NAME, which is the whole difference. These were two
 * columns at fifty per cent each, so "The Vic" and "The Bakewell Tap" were the
 * same width as each other and half the form wide whatever they said, and four
 * of them were two rows of very large buttons. Sized by their words they wrap
 * like words: four short names go on one row, four long ones on two, and the
 * block is as tall as it needs to be and no taller.
 *
 * 44px IS STILL THE FLOOR. This is a form used standing up, and a pill is not
 * an excuse for a smaller target than every other control on the screen.
 *
 * THE DETAIL LINE IS DRAWN ONLY WHEN TWO PILLS WOULD READ THE SAME, which
 * js/views/gigmode.js decides; these rules simply lay out whichever of the two
 * shapes it hands over. */
/* ============ ONE ROW, AND A + FOR THE REST ============
 *
 * Mark, 24 September 2026, from a phone: the pills wrapped on to FOUR ROWS and
 * pushed the whole form down. One row, and anything that does not fit sits
 * behind a small control.
 *
 * nowrap IS THE RULE AND THE CLIPPING IS THE POINT. js/views/gigmode.js draws
 * two pills and a + while it is collapsed, so there is nothing to clip; the
 * nowrap is what stops a long name taking a second row anyway. Expanded, the
 * row wraps as it always did, because by then the musician has asked to see
 * them and the height is theirs to spend. */
.gm-nearby-row { display: flex; flex-wrap: wrap; gap: 6px; }
.gm-nearby-row.is-one-row { flex-wrap: nowrap; }
/* HALF THE ROW EACH, LESS THE + AND THE GAPS, so two pills and the control fit
   at 320px: 292 inside the gutter, 44 for the control, 6 of gap each side. */
.gm-nearby-row.is-one-row .gm-nearby-btn { max-width: calc((100% - 56px) / 2); }
.gm-nearby-more {
  flex: 0 0 auto;
  min-width: 44px;
  min-height: 44px;
  padding: 0 10px;
  border-radius: 999px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  color: var(--text-secondary);
  font-size: var(--gm-t-small);
  font-weight: var(--weight-medium);
}
.gm-nearby-btn {
  flex: 0 1 auto;
  max-width: 100%;
  min-width: 0;
  display: inline-flex;
  flex-direction: column;
  justify-content: center;
  align-items: flex-start;
  text-align: left;
  padding: 4px 14px;
  border-radius: 999px;
  background: var(--surface);
  border: 1px solid var(--border-strong);
  color: var(--text-primary);
  min-height: 44px;
}
.gm-nearby-name {
  display: block;
  max-width: 100%;
  font-size: var(--gm-t-small);
  font-weight: var(--weight-medium);
  /* One line, clipped. A wrapped pub name turns a row of four into a block of
     eight and the pill moves under the thumb that is reaching for it. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.gm-nearby-where {
  display: block;
  max-width: 100%;
  font-size: calc(11px * var(--font-scale));
  color: var(--text-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* QUIETER, WHICH IS THE VISUAL HALF OF THE RANKING. A seeded venue nobody has
   played is offered and is plainly not the same kind of answer as a room
   somebody has actually gone live at. Opacity rather than a colour, so it
   holds in all five themes without five rules. */
.gm-nearby-btn.is-quiet, .gm-venue-option.is-quiet { opacity: .72; }
.gm-nearby-btn.is-quiet { border-style: dashed; }

/* THE SECOND LINE IS WHERE THE DETAIL LIVES, which is the other half of the
   pills being name only: the town, the postcode and the distance are worth
   reading when somebody is choosing between rooms they cannot see, and are
   noise on a shortlist of rooms they are standing in. */
.gm-venue-option-name { display: block; }
.gm-venue-option-where {
  display: block;
  font-size: calc(12px * var(--font-scale));
  font-weight: var(--weight-normal);
  color: var(--text-secondary);
  margin-top: 2px;
}

/* THE DUPLICATE QUESTION IS DRAWN INTO THE DROPDOWN, so it drops the border
   and the background it carries elsewhere rather than wearing a second set
   inside the first. It keeps its padding, which is what stops the two buttons
   touching the box. */
.gm-venue-list .gm-venue-dupe { border: 0; background: none; }

/* ============ GREEN, AMBER, RED, AS ONE DOT IN A LINE ALREADY THERE ========
 *
 * Mark's rule, 24 September 2026, and § VN10. It goes in the hint line under
 * the field, beside the venue's town, so the whole thing costs this form no
 * height at all. See venueLocationState() in js/venue-picker.js for what each
 * one means; what matters here is that it is drawn as a state and not as a
 * message, which is why green and amber have no words on the screen.
 *
 * COLOUR IS NOT THE ONLY DIFFERENCE. Green is filled and amber is a ring, so
 * the two states that look alike to somebody who cannot tell the colours apart
 * do not also feel alike. Red is filled and says so in words beside itself,
 * which is the one that has to carry. Every dot has an aria-label either way:
 * a colour is never a label.
 *
 * VERTICAL ALIGNMENT IS MIDDLE AND NOT BASELINE. An empty inline-block sits on
 * the baseline by its bottom edge, which put the dot a couple of pixels below
 * the line of type it belongs to. */
.gm-loc {
  display: inline-block;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  vertical-align: middle;
  /* It is beside words, so it moves with them rather than sitting at a fixed
     offset from a line height it does not know. */
  margin-right: 2px;
}
.gm-loc-green { background: var(--positive); }
/* ============ AND BLUE IS A SKY BLUE, NOT THE ACTION PURPLE ============
   This was var(--action), and Mark saw the dot come out PURPLE. It was the
   wrong reading on my part: --action is the PRIMARY action colour, the Go Live
   button and the selected segment, so a purple dot says "press me" rather than
   "this is how it is". --nearby is a sky blue leaning to cyan, around 193
   degrees, more than twenty degrees of hue from the Pro badge's #006CFF and
   nowhere near the purple. Filled like green, because both are answers; amber
   stays the ring. */
.gm-loc-blue { background: var(--nearby); }
.gm-loc-amber { background: none; border: 2px solid var(--notice); }
.gm-loc-red { background: var(--negative); }

/* ============ AND THE PILLS CARRY THE SAME TWO ============
 *
 * Mark, 24 September 2026: one vocabulary on the screen. A pill you are
 * confirmed to be inside is green, one that is merely near is blue, and they
 * mean exactly what the dot under the field means because they are decided by
 * the same function.
 *
 * THE COLOUR IS THE EDGE, NOT THE FILL. These are still one-tap choices and a
 * filled pill would read as a chosen one; the border says which is which
 * without any of them looking selected. Nothing is ever selected for anybody,
 * which is the rule the whole row is built on. */
.gm-nearby-btn.is-here { border-color: var(--positive); }
.gm-nearby-btn.is-near { border-color: var(--nearby); }

/* THE OPTIONAL TOWN, in the space the empty suggestion list leaves. */
.gm-newtown-slot:empty { display: none; }
.gm-newtown { margin-top: 8px; }
.gm-newtown-label { margin-bottom: 4px; font-size: var(--gm-t-small); }
.gm-newtown-input { height: calc(48px * var(--density)); }

/* WHAT KIND OF PRIVATE GIG. On one row with its label, the same shape as the
   length row below, because it appears in the same place in the reading order
   and a second shape would read as a second kind of thing. */
/* WHAT KIND OF PRIVATE GIG. Inside the Private gig switch's own .gm-sec, not a
   section of its own: it is the rest of the answer to that switch rather than a
   setting beside it, and a second heading for half a question would say
   otherwise. Label and control on one row, which is this form's shape. */
/* ============ WHAT KIND BELONGS TO THE SWITCH, NOT TO WHAT FOLLOWS ========
 *
 * Mark, 24 September 2026, from a phone: with Private gig on, What kind crowded
 * My song list and read as if it belonged to it.
 *
 * IT IS A SPACING RATIO AND THE FORM ALREADY HAS THE ANSWER. --gm-note-gap is
 * what binds a sentence to the row it explains, and What kind is the same kind
 * of thing: the rest of the answer to the switch above it. It took the same ten
 * pixels the sections use between each other, so the eye had no reason to
 * group it upward rather than downward.
 *
 * SO IT TIGHTENS ABOVE and the section keeps its whole --gm-group-gap below,
 * which is the convention every other block on this form follows. Nothing new
 * is invented: no rule, no box, no heading. */
.gm-eventrow {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  flex-wrap: wrap;
  margin-top: var(--gm-note-gap);
}
/* AND THE SECTION IT IS IN ALREADY KEEPS ITS FULL GAP BELOW: .gm-sec's own
   margin is var(--gm-group-gap) and nothing here changes it. Said rather than
   restated, because a :has() rule that duplicates an existing value would be a
   dependency on a selector Safari only gained at 15.4, which is this product's
   minimum, bought for nothing. The whole fix is the ratio between the two. */
.gm-eventrow-label {
  font-size: var(--gm-t-small);
  font-weight: var(--weight-medium);
  color: var(--text-secondary);
}
.gm-event-select {
  flex: 1 1 160px;
  min-width: 0;
  max-width: 100%;
  height: calc(44px * var(--density));
  border-radius: var(--radius);
  background: var(--surface);
  border: 1px solid var(--border-strong);
  color: var(--text-primary);
  font-size: calc(15px * var(--font-scale));
  padding: 0 10px;
}

/* THE NEAR-DUPLICATE QUESTION, drawn where the suggestions are. It offers and
   never merges: a venue carries history and joining two rooms that are not the
   same room is not something the musician who did it can undo. */
.gm-venue-dupe {
  padding: 10px 12px;
  border-radius: var(--radius);
  background: var(--surface);
  border: 1px solid var(--border-strong);
}
.gm-venue-dupe-body { margin: 0 0 8px; font-size: var(--gm-t-small); }
.gm-venue-dupe-row { display: flex; gap: 8px; flex-wrap: wrap; }
.gm-venue-dupe-row button { min-height: 44px; flex: 1 1 120px; }

/* The two quiet links that live inside the hint line, which is already
   reserved, so neither costs the form a row.
   THE BORDER COMES OFF AS WELL, 24 September 2026. .btn-quiet carries a 1px
   --border-strong outline for a button, and these are not buttons: with the
   padding and the height taken off it, what was left was a hairline box drawn
   tight around two underlined words in the middle of a sentence. Underlined
   text in the hint line is the affordance; a box round it is a second one
   saying something different. */
.gm-notright, .gm-widen {
  min-height: 0;
  padding: 0;
  font-size: inherit;
  text-decoration: underline;
  background: none;
  border: 0;
}
.gm-switch {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  /* THE 44px FLOOR IS STATED HERE AS WELL AS IN --gm-row. A review measured
     these rows as the shortest things on the form, and the reason they are not
     is that --gm-row happens to be 44 or more at every width. Happens to be is
     not a guarantee: the floor is the whole row's, not the token's, so a
     future value below 44 cannot quietly take the tips switch under the size
     every other control on this form holds. */
  min-height: max(44px, var(--gm-row));
}
/* .toggle carries a 14px top margin for the profile page's stacked switches,
   and this one is a row in a form where the gap above it is the section's. */
.gm-setup .gm-switch { margin: 0; }
/* ============ AND THE TWO SWITCH HEADINGS ARE ANSWERS TOO ============
   23 September 2026. "Paid requests" and "Tips" are not captions naming a
   field: the switch beside each of them is the field, and the heading plus the
   word is the whole answer, read at arm's length in a dark room. They were
   --gm-t-body, the caption size, which the phone ramp takes to 13px: the two
   rows a musician actually checks before a set sat four pixels under the
   picker's own "All songs". See --gm-t-choice. The four captions on the form do
   not move, which is the other half of the change.

   AND THE WEIGHT WITH THE SIZE, 23 September 2026. --weight-normal, which is
   what a <select> renders its own text at, for the reason set out beside the
   mode segment above: matching one of the two and not the other leaves the
   words still reading as a different kind of word, which is what a reviewer
   measured at 3x zoom. The Pro pill keeps its own 700, declared outright in
   css/shared.css, because a mark is not a label. */
.gm-setup .gm-switch .gm-h {
  flex: 1 1 auto;
  min-width: 0;
  margin: 0;
  font-size: var(--gm-t-choice);
  font-weight: var(--weight-normal);
}
/* SCOPED TO .gm-setup, 23 September 2026. This was a bare .gm-switch-state
   reading --gm-t-choice, a token declared only on .gm-setup: correct today
   because setupSwitchMarkup is the only thing that draws this element and it
   draws it only on this form, and silently wrong the day somebody reuses the
   switch anywhere else, where the size would fall back to the browser's
   default with nothing saying so. The selector now says where it applies. */
.gm-setup .gm-switch-state {
  flex: none;
  /* ============ THE WORD RIDES WITH THE HEADING IT ANSWERS ============
     23 September 2026, and this half is a judgement rather than an instruction:
     the row is one sentence, "Paid requests ....... On", and leaving the state
     at the old caption size while the name of the setting went up would have
     made the ANSWER the smaller of the two words on a row whose whole purpose
     is to say what the answer is. One size and one weight for one row. */
  font-size: var(--gm-t-choice);
  font-weight: var(--weight-normal);
  color: var(--text-primary);
  min-width: 2.4em;
  text-align: right;
}
/* A PREREQUISITE THE MUSICIAN HAS NOT MET YET, not a tier. The control is on
   the screen and says Off, and the line under it says what to do. Never the
   Pro lock's treatment, and never .pro-locked or a lock mark: nothing on this
   form is locked for a tier, and this state is a step not yet taken.

   ============ THE CONTROL DIMS, THE NAME OF THE SETTING DOES NOT ==========

   22 September 2026. This rule was `.gm-switch.is-unavailable { opacity: 0.6 }`
   on the whole row, which was itself a correction of a version that dimmed the
   track and the word alone. It goes back to dimming the control, on a
   measurement rather than on an argument.

   WHAT THE WHOLE-ROW VERSION MEASURED. In the Pro-with-no-payments state the
   row's own title "Paid requests" came out at 4.75:1 in the light theme and
   5.89:1 in the dark one, while the sentence underneath it, which is outside
   .gm-switch and was never dimmed, sat at 9.18:1 and 7.74:1. The name of a
   setting was the faintest thing in its own block, fainter than the note
   explaining it and fainter than the Gig length label below, which is a
   hierarchy upside down. The Pro mark went with it, to 2.9:1 and 3.2:1, under
   4.5 in both themes.

   SO THE LABEL AND THE MARK STAY AT FULL STRENGTH and only the parts that
   cannot be operated are dimmed: the track, the knob inside it, and the word
   beside it. At 0.6 that word holds 4.8:1 light and 6.1:1 dark, so the state
   is still legible rather than merely visible.

   THE ROW DOES NOT READ AS HALF LOADED, which was the fair objection to this
   treatment the first time. Three things now say the same thing at once: a
   grey track, the word Off, and a sentence naming the step that has not been
   taken. What made the earlier version look like a rendering fault was the
   Pro mark at full saturation beside a greyed switch with no sentence tying
   them together, and that sentence is under both rows now.

   ============ AND THE WORD CAME OUT OF THE DIM, 23 SEPTEMBER 2026 ==========

   A reviewer put the objection above back, precisely: the toggle and the
   On/Off word go grey and the label does not, so half the row reads dead and
   half reads live. Make the whole row read one way.

   DIMMING THE WHOLE ROW IS STILL REFUSED, and this time with the arithmetic
   written down rather than recalled. Measured against the page in each theme,
   a uniform opacity on the row puts the Pro pill's FILL at 3.26:1 Dark and
   2.96:1 Light at 0.85, at 2.78 and 2.65 at 0.75, and at 2.17 and 2.17 at 0.6.
   The pill is a filled shape carrying meaning, so it answers to WCAG 1.4.11's
   3:1, and there is no useful strength of dim that keeps it there. Exempting
   the pill would be the same split one element smaller.

   SO THE ROW READS ONE WAY BY THE OTHER ROUTE: every WORD in it is at full
   strength. The label, the Pro mark and the answer are one size, one weight
   and one colour, and the only thing still dimmed is the one thing that
   genuinely cannot be operated, which is the track. That is not a split, it is
   a control that is inactive inside a row that is not: WCAG 1.4.11 exempts
   inactive components from the contrast floor for exactly this reason, and the
   sentence underneath names the step that has not been taken.

   WHAT IT COSTS, SAID PLAINLY. The word Off is now as strong as the word On
   would be, so the row no longer whispers its unavailability twice. It says it
   once, in the track, and once in words, in the line below.

   BOTH ROWS TAKE IT IDENTICALLY, which needs saying because a review measured
   "Paid requests" dimmed and "Tips" at full strength in the same state. Both
   are drawn by setupSwitchMarkup in js/views/gigmode.js with the same class
   from the same condition, canTakeTips(), so there is one treatment here and
   no second rule anywhere that could pull them apart.

   ============ AND THE TRACK SAYS IT IN SHAPE, NOT ONLY IN ALPHA ============

   23 September 2026, and this is the regression the change above introduced.
   Taking the dim off the words was right and neither reviewer wants it back,
   but it left ONE availability cue on the whole row: 40% alpha on a 1.5px
   hairline and a 21px knob. That is the weakest channel there is, on the
   smallest element on the row, read at half a metre in bad light. A disabled
   track and an ordinary switched-off track differed by nothing else at all.

   THE KNOB GOES HOLLOW. It is the largest solid shape in the control, so
   turning it from a filled disc into a ring is a change in the strongest
   channel available, and it is a change of SHAPE, which survives glare, a
   dimmed screen and a colour-blind eye in a way that alpha does not. Dashing
   the track's own edge was the alternative and was rejected on the drawing:
   1.5px of dash around a 999px radius at 50x30 reads as a rendering fault
   rather than as a state.

   THE ALPHA STAYS ON TOP OF IT, because the two say the same thing in two
   channels and the reviewers asked for a second cue rather than a different
   one. Both are on the TRACK and neither is on a word.

   INSIDE THE 1.4.11 EXEMPTION, AND COMFORTABLY CLEAR OF IT ANYWAY. This is an
   inactive component, so neither the ring nor the dimmed hairline owes 3:1.
   The ring is drawn in the knob's own colour, --text-secondary, and measures
   8.11:1 in Dark and 11.98:1 in Light against the sunken track it sits in;
   with the row's own 0.6 composited over the page it is still 3.64:1 and
   3.84:1. It is there to be SEEN as a different shape rather than read, and it
   would pass the floor it is exempt from. */
.gm-switch.is-unavailable .toggle-track { opacity: 0.6; }
/* The ring's thickness is stated rather than derived: the knob is 21px, and
   2px leaves a 17px hole, which is the smallest opening that still reads as a
   hole rather than as a slightly paler disc at arm's length. box-sizing is
   already border-box on everything here, so the ring is drawn inside the 21px
   and the knob does not grow or move. */
.gm-switch.is-unavailable .toggle-knob {
  background: transparent;
  border: 2px solid var(--text-secondary);
}
.gm-switch.is-unavailable input { pointer-events: none; }

/* ============ A PREREQUISITE SAID IN A SENTENCE, NOT IN A BANNER ============
 *
 * .gm-needs-setup is the "Payments are not set up" line under the paid requests
 * switch and .gm-tips-off is the tips line, which is under the tips switch as
 * of 22 September 2026 and used to be under the Go Live button. Both were
 * a sentence followed by a .btn-quiet, a bordered 44px block, and between them
 * they stood about 145px tall on a 320px handset. That is the whole of why a
 * Pro account with no payments connected could not see the Go Live button: the
 * one state being told how to fix something was the one state that could not
 * reach the button.
 *
 * The words and the destination are unchanged. The action is a link inside the
 * sentence now, which is what it always was in meaning.
 *
 * HOW IT KEEPS ITS 44px WITHOUT BEING 44px TALL. The hit area is an absolutely
 * positioned ::after over the link, the same trick .gm-stat-tips uses on the
 * live bar further down this file. It reaches up into the sentence above it,
 * which is text and has nothing to hit, and down into the paragraph's own
 * bottom margin. It is inside the paragraph's margin box in both directions, so
 * it cannot take a tap from the switch above or the one below. */
.gm-setup .gm-inline-cta {
  display: inline;
  position: relative;
  min-height: 0;
  padding: 0;
  border: 0;
  background: none;
  border-radius: 0;
  /* THE ACTION DOES NOT BREAK IN HALF. "Set up payments" was splitting across
     two lines as "Set / up payments", which reads as a typing mistake rather
     than as a link, and it also splits the hit area below: an absolutely
     positioned ::after on a wrapped inline covers the first fragment only, so
     half the words a thumb aims at would have had no target under them. */
  white-space: nowrap;
  font: inherit;
  font-weight: var(--weight-strong);
  color: var(--action-on-surface);
  text-decoration: underline;
  text-underline-offset: 3px;
}
.gm-setup .gm-inline-cta::after {
  content: "";
  position: absolute;
  inset: -14px -6px -13px;
}
/* THE UPWARDS-ONLY HIT AREA UNDER GO LIVE IS GONE WITH THE NOTE THAT NEEDED
   IT. `.gm-setup .gm-tips-off .gm-inline-cta::after { inset: -27px -6px 0 }`
   was here because the tips note was the last element in the form: anything
   hanging below it was added to the page's scroll height, and a form whose
   whole purpose is to need no scroll cannot pay thirteen pixels for a hit
   area. The note sits under the tips switch now, with its own bottom margin
   and the slot's underneath that, so the ordinary inset above fits inside the
   margin box in both directions and the rule is not needed.

   ============ EACH NOTE BELONGS TO THE ROW ABOVE IT ============

   Measured before this, the payments note sat 19px below the switch it is
   about and 18.5px above the switch it is not, so it read as floating between
   two rows rather than explaining one. Both notes take --gm-note-gap above,
   and the payments note takes the difference between the group gap and the
   pair gap below it, so the tips row underneath starts a group's distance away
   while the two rows themselves stay a pair's distance apart when there is no
   note between them.

   ============ AND THE TIPS NOTE NEEDED A GAP UNDER IT, 22 SEPTEMBER =========

   What was here said the tips note needs no bottom margin of its own, because
   it is the last thing in the money slot and the slot's own bottom margin is
   already the group gap. On a tall screen that is true: 18.5px above the note
   and 33 below it at 390. On the two short ones it was not, because the group
   gap comes down to 12 and 10 there while the seven pixels of row air above
   the note do not come down at all. Measured: 13 above and 11.5 below at 320,
   15 and 16.5 at 375, which is a sentence no more attached to the row it
   explains than to the heading of the next section.

   So --gm-note-after is added under it at those two widths, and --gm-note-gap
   comes down above it, which is the same pixel spent twice. It costs the form
   nothing on the screens that have nothing to spare: the note only exists in
   the one state that draws it, and the two changes very nearly cancel.

   1.4 RATHER THAN THE DOCUMENT'S 1.5, for the same reason the headings have
   their own: these are two-line sentences at 12px, and the looser figure is
   set for paragraphs of running text rather than for a note under a switch.

   ============ READ THIS BEFORE EDITING EITHER SENTENCE ============

   23 September 2026. BOTH OF THESE NOTES ARE TWO LINES AT 320px AND THE SECOND
   LINE IS ALMOST FULL. Measured in the tallest state this form can reach, the
   tips notice's first line ends at 304.5 in a content box that ends at 306.0:
   one and a half pixels of slack. One more character, one different font
   fallback, or a rewording that adds a syllable makes it a THIRD line, which
   is about 23 pixels, and at 320x568 the Go Live button has ten. A third line
   puts the button roughly thirteen pixels below the bottom of the screen on
   the one form whose standing rule is that the button is reachable without
   scrolling. Two single-figure margins stacked on one handset.

   tests/golive-fits.mjs FAILS IF EITHER NOTE REACHES A THIRD LINE at 320 in
   that state, so a copy edit turns a suite red rather than shipping a form
   nobody can start a gig from. Do not weaken that check to make a sentence
   fit; shorten the sentence, or find the pixels somewhere else on the form.

   AND A LINE THAT FITS IN DARK DOES NOT PREDICT LIGHT. The two themes carry
   different weight tokens: css/themes.generated.css gives Dark 400/500/600 for
   --weight-normal, --weight-medium and --weight-strong, and Light 450/600/700.
   Same string, same size, same cap height, more ink. Measured on the paid
   card's title, which is set in --weight-strong: 239.0px of text in Dark
   against 242.0 in Light, into a 242.0 line box, so it wraps in one theme and
   not the other. Roughly two per cent, which only shows on a line that is
   already nearly full, which is exactly the kind of line this note is about.
   MEASURE BOTH THEMES, and measure Light first, because it is the wider one.
   The tokens are deliberate and are not to be chased. */
.gm-setup .gm-needs-setup {
  margin: var(--gm-note-gap) 0 var(--gm-note-pair);
  line-height: 1.4;
}
/* At the size of every other hint on the form rather than at a hard-coded
   14px, and left-aligned with everything else on the page rather than centred
   on its own. Centred text under a left-aligned form was one of the things
   that read as pieced together. */
.gm-setup .gm-tips-off {
  margin: var(--gm-note-gap) 0 var(--gm-note-after);
  font-size: var(--gm-t-small);
  line-height: 1.4;
  color: var(--text-secondary);
  text-align: left;
}

/* ============ THE MONEY SLOT: TWO PEER ROWS, ONE GAP BETWEEN THEM ==========
 *
 * A flex column rather than two blocks, so the gap between the rows is one
 * declared number instead of whichever margin happened to win a collapse.
 * The rows carry no margin of their own for the same reason: --gm-pair-gap is
 * the only thing between them, and #gmChargingSlot's own bottom margin is the
 * only thing between the pair and the length section below.
 *
 * AN EMPTY SLOT IS NOT A GAP. A Standard account is drawn neither row, and
 * paintSetupCharging leaves the div empty rather than removing it, so the
 * bottom margin has to come off or that account gets a group's worth of
 * nothing between the request mode and the gig length. */
#gmChargingSlot {
  display: flex;
  flex-direction: column;
  gap: var(--gm-pair-gap);
  margin-bottom: var(--gm-group-gap);
}
#gmChargingSlot:empty { margin-bottom: 0; }
.gm-setup .gm-sec-switch { margin: 0; }

/* ============ AND THE PRIVATE GIG SLOT NEEDS THE SAME, 24 SEPTEMBER =========
 *
 * Mark, from a phone: with Private gig on, What kind crowded My song list and
 * read as if it belonged to it. MEASURED AT ZERO PIXELS between them, at every
 * width, which is a good deal worse than crowded.
 *
 * WHY, AND IT IS THE RULE ABOVE. A switch section carries no margin of its own,
 * because inside the money slot the spacing is that slot's job. Private gig is
 * drawn into a slot of its own that had no such rule, so nothing anywhere
 * supplied the gap: the section's margin is zero by that rule, and a bare div's
 * own margin is zero because margins do not collapse out of a flex item. The
 * block simply ended where the next heading began.
 *
 * So the slot carries the group gap, exactly as the money slot does. Same
 * convention, same token, nothing invented. The :empty guard is there for the
 * same reason it is there above: the slot is in the markup before the event
 * types have loaded, and an empty div must not be a gap. */
#gmPrivateSlot { margin-bottom: var(--gm-group-gap); }
#gmPrivateSlot:empty { margin-bottom: 0; }

/* ============ THE LENGTH: ONE CONTROL, THREE EQUAL CELLS ============
 *
 * Mark: "the minus button, the time and the plus button must be properly
 * aligned as one full-width control, even sizes, even gaps, shared centre
 * line."
 *
 * WHAT WAS ACTUALLY WRONG, and it was not the grid. This file declares .gm-step
 * twice. The Room panel's break stepper, about four thousand lines further
 * down, redeclares it as flex:none, 48px wide, 48px tall, font-size 20px and
 * --radius, and it wins on source order because both selectors are one class.
 * So the Go Live form asked for a 74px column and drew a 48px button inside it,
 * left-aligned, with a tighter corner than the value box beside it and a
 * different glyph size from the one it was given. Two buttons of the wrong
 * width, sitting off-centre in their columns, either side of a box with a
 * rounder corner: that is the misalignment, and no amount of tuning the grid
 * would have touched it.
 *
 * SO EVERY RULE HERE IS SCOPED TO .gm-setup, which beats one bare class
 * whatever the source order, and the Room panel's stepper is left alone.
 *
 * THE TWO BUTTONS ARE SQUARES of exactly --gm-row, which is the height of the
 * row, so "even sizes" is true by construction and stays true at all three
 * widths. The value takes the rest. One gap, declared once, on both sides. */
.gm-sec-length { margin-bottom: 0; }
.gm-setup .gm-stepper {
  display: grid;
  grid-template-columns: var(--gm-row) minmax(0, 1fr) var(--gm-row);
  gap: 10px;
  align-items: stretch;
}
.gm-setup .gm-step,
.gm-setup .gm-duration {
  width: 100%;
  height: var(--gm-row);
  display: grid;
  place-items: center;
  padding: 0;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  color: var(--text-primary);
  font-size: var(--gm-face);
  line-height: 1;
}
/* ONE FILL FOR ALL THREE CELLS. The value box took --surface and the two
   buttons --surface-raised, which in the light theme is #F7F6F3 against
   #FFFFFF: one control drawn on two surfaces, with the seam falling exactly
   where the eye is trying to read a single stepper. They share the raised
   fill, which is the one that lifts off the page background in both themes. */
.gm-setup .gm-step,
.gm-setup .gm-duration { background: var(--surface-raised); }
.gm-setup .gm-duration {
  font-weight: var(--weight-strong);
  font-variant-numeric: tabular-nums;
}

/* ============ GO LIVE IS PINNED TO THE FOOT OF THE SCREEN ============
 *
 * MARK'S DECISION, 24 SEPTEMBER 2026, AND IT REPLACES AN OLDER RULE.
 *
 * WHAT THE OLD RULE WAS. "A musician in a venue, minutes before a set, must be
 * able to see the button that starts the gig without scrolling for it", held by
 * tests/golive-fits.mjs at 320x568 and 375x667 and written into CLAUDE.md. It
 * was the right instinct and the wrong mechanism.
 *
 * WHY IT HAD TO GO. A no-scroll form makes every control on the screen compete
 * for the same pixels, and the screen ran out. On a 320x568 handset, on a Pro
 * account whose payments are not set up, main left TEN PIXELS of headroom. The
 * venue network needs one more switch on that form and a switch row cannot be
 * less than 44px, because 44 is the tap-target floor every other control here
 * holds. So the next control could not be added at all, and the one after that
 * would have been the same conversation again.
 *
 * WHAT REPLACES IT KEEPS THE PROMISE BETTER. Mark: "That rule exists so the
 * button is always reachable, and a pinned button keeps that promise better
 * than a no-scroll form does." The button is at the foot of the screen in every
 * state, at every width, however tall the form gets. It is not merely reachable
 * without scrolling; it is in the SAME PLACE every time, which is what a thumb
 * in a dark room is actually looking for.
 *
 * STICKY AND NOT FIXED, and that is not a style choice. A view's section in
 * this app carries container-type, which in WebKit makes it the containing
 * block for fixed-position descendants, so `position: fixed; bottom: 0` can
 * resolve against a section thousands of pixels tall and land off the screen.
 * js/modal-portal.js carries the measurement. Sticky is positioned against the
 * scrollport instead and cannot be caught by it.
 *
 * THE OFFSET IS .gm's OWN BOTTOM PADDING, to the character. That is what makes
 * the pinned position and the resting position the same position: the button
 * does not move a pixel between a form that scrolls and one that does not, and
 * it clears the home indicator on a notched phone because the safe area is in
 * the sum.
 *
 * THE SPACER IS ON THE LENGTH SECTION, NOT ON THE BUTTON, and that is the one
 * subtle part. The obvious way to pin it is margin-top: auto on the button, and
 * it is wrong: an auto margin collapses to NOTHING the moment the form
 * overflows, so at the foot of a scrolled form the button sat 11px ON TOP OF
 * THE STEPPER'S PLUS AND MINUS at 320x568. Measured, not imagined.
 *
 * The gap the button needs above it is a fixed one, so the button keeps its
 * --gm-group-gap and the section BEFORE it takes the auto margin instead. Free
 * space goes there when the form is short, and there is none to take when it is
 * tall, and the button's gap is the same in both. The arithmetic: at the foot of
 * the scroll the last control's bottom sits at (viewport - margin - button -
 * padding), and it clears the pinned button only while margin is at least the
 * gap. An auto margin cannot promise that; a declared one does.
 *
 * IT SITS UNDER THE VENUE DROPDOWN ON PURPOSE (z-index 5 against 6). If the two
 * ever meet, the musician is choosing a venue at that moment and the button is
 * the thing that can wait. Measured at all three widths: they do not meet. */
.gm-primary {
  width: 100%;
  height: var(--gm-go);
  /* The end of the last group and the start of the button, so it is the form's
     own group gap. It was calc(var(--gm-space) * 2), a figure that answered to
     nothing else on the screen and needed overriding at both breakpoints. */
  margin-top: var(--gm-group-gap);
  border-radius: var(--radius-lg);
  background: var(--action);
  color: var(--action-text);
  /* ============ A BUTTON LABEL, NOT A HEADING ============
     This was 19px, against a page title of 24 at the same width. Two reviews
     measured the pair and said the same thing: a title fourteen per cent
     bigger than a button label is not a hierarchy, it is a coincidence. The
     title has gone up and this has come down to 17, which is still the biggest
     label on the form after the title and the stepper's figure, and the button
     itself is still the biggest control on the screen by its height. */
  font-size: calc(17px * var(--font-scale));
  font-weight: var(--weight-strong);
}
/* SCOPED TO THE SETUP FORM. .gm-primary is also the wrap screen's button, which
   is on a screen with nothing to scroll and no bar to sit in. */
.gm-setup .gm-primary {
  /* NO MARGIN OF ITS OWN. The bar it sits in carries the spacing now. */
  margin-top: 0;
}

/* ============ THE SCROLLING AREA AND THE BAR UNDER IT ============
 *
 * 24 September 2026, replacing a sticky button Mark found broken on an iPhone.
 * An ordinary application layout: the screen is a flex column of exactly the
 * screen's height, the form is the one thing in it that scrolls, and the bar is
 * a sibling that does not.
 *
 * WHY THIS AND NOT STICKY. A sticky element stays in the flow and is painted
 * somewhere else, and on iOS it vanished after a few trips in and out of this
 * screen and let the form show through it while it was there. Neither symptom
 * exists here, because nothing is painted anywhere but where it sits: there is
 * no overlap to be transparent about and no repositioning to be lost.
 *
 * min-height: 0 ON THE SCROLLER IS LOAD BEARING. A flex item's default minimum
 * size is its content, so without this the form refuses to be shorter than
 * everything on it, the column grows past the screen, and the bar goes off the
 * bottom: the exact fault this replaces, arrived at a different way.
 *
 * overscroll-behavior CONTAINS THE RUBBER BAND, so dragging the last control
 * does not drag the page behind it on iOS.
 *
 * THE BAR SPANS THE WHOLE WIDTH. .gm-setup carries the 18px side gutter every
 * field lines up with, so the bar cancels it with a negative inline margin and
 * puts it back as padding: the background reaches both edges, the button inside
 * still lines up with the fields above it.
 *
 * AND IT IS OPAQUE, in every theme, because it is --bg, which is the same
 * colour the page is. Mark's screenshot had the length stepper showing through
 * a transparent button. */
.gm-setup {
  height: 100dvh;
  overflow: hidden;
  padding-bottom: 0;
}
/* ============ AND IT IS A BLOCK, NOT A FLEX COLUMN ============
 *
 * 24 September 2026, and this is the fault Mark reported as "the Start a gig
 * heading appears for a second and then vanishes for ever".
 *
 * IT WAS A FLEX COLUMN, and a flex item shrinks by default. So the moment the
 * form's content was taller than the scrolling area, which is the moment the
 * money controls arrive a round trip after the first paint, flex resolved the
 * overflow by SQUEEZING the items instead of letting the area scroll. The
 * title, which has no minimum height of its own, was shrunk to ZERO. Measured:
 * fontSize 28px, opacity 1, text "Start a gig", boundingRect height 0.
 *
 * That is why it appeared and then went: on the first frame the form fitted and
 * nothing shrank; a second later the charging slot landed, the content passed
 * the container, and the heading collapsed. And why it never came back: the
 * content stays taller. And why quitting the app brought it back for a second:
 * that is the first frame again.
 *
 * A SCROLLING AREA HAS NO BUSINESS BEING A FLEX CONTAINER. It needs to be
 * exactly one thing, a box that scrolls, and every child inside it should lay
 * itself out the way it would anywhere else on the page. It is a block now, and
 * nothing in the form relies on it being anything else: the auto-margin spacer
 * that once needed flex went with the sticky button. */
.gm-setup-scroll {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  /* AND NOTHING INSIDE IT ANCHORS. Scroll anchoring moves the view when content
     above it changes height, and on this form content above the fold arrives
     for seconds after the first paint: the pills, the money rows, the songbook
     list. The live screen's own scroller says the same thing for the same
     reason. */
  overflow-anchor: none;
}
/* ============ AND IT SAYS WHEN THERE IS MORE UNDER IT ============
 *
 * Mark, 24 September 2026: with a solid bar at the foot, the page looks
 * FINISHED at the button, and Gig length sits below the fold unnoticed. Getting
 * the length wrong means the gig ends at the wrong time.
 *
 * THREE THINGS SAY IT AND NONE OF THEM IS A COLOUR. The content fades INTO the
 * bar instead of stopping at it, which is the oldest signal there is that a
 * list continues. The bar gains a hairline and an upward shadow, so it reads as
 * something laid over the page rather than the end of it. And an arrow appears,
 * which is a BUTTON: a thumb that finds it does what the thumb was trying to
 * do.
 *
 * ALL THREE ONLY WHILE THERE IS SOMETHING BELOW. js/views/gigmode.js puts
 * .has-more on the form while the scroller has further to go and takes it off
 * at the end, so a form that fits has no line, no shadow, no fade and no arrow:
 * the foot of the page is the foot of the page and says nothing. */
.gm-setup-foot {
  flex: 0 0 auto;
  background: var(--bg);
  position: relative;
  border-top: 1px solid transparent;
  transition: border-color .18s ease, box-shadow .18s ease;
  margin-inline: calc(var(--gm-gutter) * -1);
  padding-inline: var(--gm-gutter);
  padding-top: var(--gm-group-gap);
  padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--gm-space) + 6px);
}
/* THE LAST SECTION STOPS WHERE THE BAR STARTS. .gm-sec's own bottom margin
   would otherwise put a second gap inside the scroller under the last control,
   on top of the bar's own padding. */
.gm-setup-scroll > .gm-sec:last-of-type { margin-bottom: 0; }

/* ---- the three cues, and all three are conditional ---- */
.gm-setup.has-more .gm-setup-foot {
  border-top-color: var(--border);
  box-shadow: 0 -10px 18px -10px rgba(0, 0, 0, .45);
}
/* THE FADE IS A MASK ON THE SCROLLER, not a gradient laid over it, so it works
   on every theme without knowing the page's colour and cannot sit on top of
   anything a thumb is reaching for. The last 28 pixels of the scrolling area
   dissolve, which is what says the list carries on underneath. */
.gm-setup.has-more .gm-setup-scroll {
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 28px), transparent 100%);
  mask-image: linear-gradient(to bottom, #000 calc(100% - 28px), transparent 100%);
}
.gm-more-below {
  position: absolute;
  top: -19px;
  left: 50%;
  transform: translateX(-50%);
  width: 38px;
  height: 38px;
  border-radius: 50%;
  background: var(--surface-raised);
  border: 1px solid var(--border);
  color: var(--text-secondary);
  display: none;
  align-items: center;
  justify-content: center;
  /* A grid rather than flex alignment for the glyph, so the SVG is centred on
     both axes by the box rather than by a font's idea of where a character
     sits. Mark's arrow was visibly high in its circle. */
  padding: 0;
  box-shadow: 0 2px 8px -2px rgba(0, 0, 0, .5);
  /* It sits over the fading content, and it is the one thing up there that is
     meant to be pressed. */
  z-index: 2;
}
.gm-setup.has-more .gm-more-below { display: flex; }
/* The chevron is decoration inside a button, so it never takes a tap of its own
   and never reports itself to a screen reader. 20px in a 38px circle leaves the
   nine pixels of air that make a round button look round. */
.gm-more-below > svg { width: 20px; height: 20px; display: block; }

/* NOTHING MOVES FOR SOMEBODY WHO HAS ASKED FOR NOTHING TO MOVE. The line, the
   shadow and the arrow all stay; only the sliding does. */
@media (prefers-reduced-motion: reduce) {
  .gm-setup-foot { transition: none; }
}


/* ============ THE KNOB IS WHITE IN BOTH THEMES ============
 *
 * css/dashboard.css draws the checked knob in --action-text, which is the
 * colour that sits ON the action colour: #FFFFFF in the light theme and
 * #150920 in the dark one, because dark's action is a light purple and its
 * button text is nearly black. On a button that is right. On a 21px circle
 * inside a 50px track it is not: the knob disappears into the page and an On
 * switch reads as a hole punched in a purple pill. The light theme's white
 * knob is the correct treatment and this screen uses it in both. Measured
 * against dark's --action (#B36BEE) white is 2.4:1, which is what a moving
 * part inside a filled track needs to be seen rather than read. */
.gm-setup .toggle input:checked + .toggle-track .toggle-knob { background: #FFFFFF; }

/* ============ THE FOLD, ON THE TWO HANDSETS THAT DECIDE IT ============
 *
 * Go Live is the button this screen is for, and a musician who has to scroll to
 * reach it minutes before a set has been given a worse screen than they had.
 * tests/golive-fits.mjs holds a 375x667 iPhone SE and a 320x568, on every tier,
 * and tests/golive-mode-line.mjs holds the 375px figure.
 *
 * THE THIRD STATE THAT WAS NEVER MEASURED, and it was broken: a Pro account
 * with no Stripe connection drew the "Payments are not set up" notice and the
 * tips notice, and ended 628px down a 568px screen with 143px of scroll under
 * it. Both notices are sentences with links in them now, and the two switch
 * rows above them are single rows, which is where that 143 came from.
 *
 * WHAT THESE BLOCKS DO IS REDEFINE A HANDFUL OF NUMBERS, not restate the
 * layout. Everything above sizes off --gm-row, --gm-go, --gm-title-size,
 * --gm-face and the three rhythm gaps, so a tighter screen is a shorter list
 * of values rather than twenty overrides, and no tap target goes below 44px.
 *
 * ============ AND ONE OF THEM ASKS ABOUT HEIGHT, 22 SEPTEMBER 2026 ==========
 *
 * The two blocks below this one are width queries, which is right for type and
 * row heights: they answer how much room a line of text has. The fold is not a
 * width question at all. A 375x667 handset and a 390x844 one are one pixel
 * apart in the ramp and 177 apart in what they can hold, and everything a
 * review asked for here (a dominant title, a real gap between groups, 44px
 * rows, a header anchored under the ✕) is paid for in height. Measured on a
 * Pro account with no payments, the generous rhythm needs about 705px of form;
 * a 390x844 screen has 780 for it and a 375x667 has 631.
 *
 * So the rhythm tightens on short screens rather than on narrow ones, which is
 * where the pixels actually run out. The query is max-height: 740px, which is
 * every handset shorter than a 390x844, and it is qualified with min-width so
 * that the 320px block below keeps the last word on its own numbers. */
@media (max-width: 430px) {
  .gm-setup {
    /* THE TITLE IS THE BIGGEST THING ON THE FORM AGAIN. It was 24px here,
       against a 19px button label and a 20px stepper figure, and both reviews
       measured the same thing: a page title fourteen per cent bigger than a
       button is not read as a title. At 28 against 17 and 20 the order is
       plain at a glance, and it is still the largest text at all three widths
       because the figures below come down with the screen. */
    --gm-title-size: calc(28px * var(--font-scale));
    --gm-row:        calc(50px * var(--density));
    --gm-face:       calc(20px * var(--font-scale));
    --gm-go:         calc(64px * var(--density));
  }
  .gm-sub { margin-bottom: var(--gm-space); }
  .gm-mode-seg button { min-height: calc(36px * var(--density)); }
  .gm-setup .gm-stepper { gap: 8px; }
}

/* ============ THE TALL HANDSET: A 390x844, AND THE MONEY ROWS PAIR UP =======
 *
 * 22 September 2026, and this block is new. Everything above answers to width,
 * which is right for type and for how much room a line of text has, and the
 * two blocks below answer to height, which is right for the fold. This one
 * answers to both, because it describes one screen: a phone as wide as any
 * other but tall enough to keep the generous rhythm, which is the 390x844 both
 * reviews are held on. Nothing in it is about room running out.
 *
 * WHAT IT FIXES. Two reviews measured the money slot at this width. The gap
 * from the payments note to the Tips row was 33.5px while the gap that ENDS
 * the section, from the tips note to the Gig length label, was 25 and the gap
 * above Go Live was 22. The largest space on the page fell between two sibling
 * rows, so Tips read as the start of something new and Gig length read as
 * attached to the note above it. With the notes absent it was 32 above, 30
 * between and 35 below: the right order, but three gaps within five pixels of
 * each other are not a pair and two groups.
 *
 * THE THREE FIGURES. --gm-pair-gap goes to 2px, the same value and for the
 * same reason as the 375 block below: these rows are 50 tall around a 30px
 * track, so ten pixels of each row's own air sit either side of whatever is
 * declared between them, and 2 of box is 22 of visible space against 32 above
 * and 32 below. --gm-note-pair is the gap under the payments note when the
 * Tips row follows it, which used to be a whole group gap; 8 puts the note 20
 * visible pixels off the next track against the 16 that bind it to its own.
 * --gm-note-after is the space under the last note in the slot, and it is what
 * makes the end of the section read like the end of every other one: see the
 * token itself for why a note needs more than a row does.
 *
 * NEITHER SHORT SCREEN CAN SEE ANY OF THIS. max-height 740px and min-height
 * 741px do not overlap, so the blocks below cannot be reached from here and
 * this cannot be reached from there, which is what both reviews asked for:
 * 320 and 375 already order these three gaps correctly. */
@media (min-height: 741px) and (max-width: 430px) {
  .gm-setup {
    --gm-pair-gap:   2px;
    --gm-note-pair:  8px;
    --gm-note-after: 8px;
  }
}

/* SHORTER THAN A 390x844 HANDSET. The 375x667 iPhone SE is the screen this
   block exists for, and tests/golive-fits.mjs holds it. Every number here is
   the answer to one measurement: with the tall screen's rhythm, a Pro account
   with no payments ended 78px below the bottom of this one. The gaps come in
   by ten, the rows sit on their 44px floor rather than 50, the button comes
   down to 52, the title drops a step, and the form ends about 8px clear of the
   bottom with the header still anchored under the ✕. */
@media (max-height: 740px) and (min-width: 361px) {
  .gm-setup {
    --gm-title-size: calc(25px * var(--font-scale));
    --gm-row:        calc(44px * var(--density));
    --gm-go:         calc(52px * var(--density));
    --gm-label-gap:  5px;
    --gm-group-gap:  12px;
    /* ============ THE PAIR IS A PAIR AGAIN HERE, 22 SEPTEMBER 2026 =========
       This was 8px, and at this width that made the gap BETWEEN the two money
       rows the largest of the three: 22px of visible space against 19 above
       the pair and 22 below it, measured track to track. The reason is that
       the rows sit on their 44px floor here around a 30px track, so seven
       pixels of each row's own air are added to whatever gap is declared
       between them, and the group gaps either side are only 12. Two pixels of
       box gap is sixteen of visible space, which is the smallest of the three
       by three pixels and reads as the pair it is. Nothing else moves: the
       payments note's bottom margin is the group gap minus this one, so the
       state with a note between the rows is the same height it was. */
    --gm-pair-gap:   2px;
    /* --gm-head-gap WAS 8px HERE AND IS GONE, 22 September 2026. It is the air
       under the ✕'s band before the title starts, and this screen has no band
       under to be: the max-height block below puts the title on the ✕'s own
       line, so the token is not read at this width any more and a value left
       here would be a number that changes nothing. */
    /* Three pixels above a note and eight below it, for the reason set out
       beside --gm-note-gap: seven of the row's own air already sit above the
       note, so three buys ten, and the group gap of 12 below it does not on
       its own say the next heading is further away than the row is. */
    --gm-note-gap:   3px;
    --gm-note-after: 8px;
    padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 8px);
  }
}

/* ============ THE SHORT SCREENS SHARE THE ✕'s BAND ============
 *
 * 22 September 2026, and this is the answer to the one hard-constraint failure
 * left in this pass. The state that breaks it is the tallest the form can
 * reach and nothing had ever measured it: a Pro account with payments NOT
 * connected, so both notices are drawn, AND a songbook chosen while the
 * request mode is "My list only", which puts the strict warning under the
 * picker as well. tests/golive-fits.mjs sets it up now. Measured before this,
 * Go Live ended 673px down a 667px screen at 375x667 and 577px down a 568px
 * one at 320x568: a form whose entire purpose is to be answered without
 * scrolling could not be.
 *
 * WHAT 375 WAS SPENDING. It reserved a whole band for the ✕ before the title
 * began: 12px of padding, the circle's own 36, and 8 of --gm-head-gap. That is
 * 56 pixels before the first word, and 44 of them buy nothing but the ✕ having
 * a line to itself. The state above was 14 pixels over. The 320px block below
 * has never been able to afford that band and has always shared it, so this
 * rule is that screen's treatment applied to the other short one rather than
 * anything new, and it is stated once here for both instead of twice.
 *
 * SO THE TEST IS HEIGHT, NOT WIDTH, which is the same reasoning as the block
 * above this one: the fold is a question about how tall a screen is. A 390x844
 * handset has the room and keeps the title under the ✕'s own band, which is
 * the better treatment wherever it is affordable.
 *
 * NOTHING ABOUT THE ANCHOR CHANGES, which is the part worth being clear on.
 * The fault the anchor was written for was a title that landed in a different
 * place for every account, because the form was centred in whatever height was
 * left over. The offset here is declared and identical in all three states at
 * a given width, which is the whole of that fix. The title's padding-right
 * keeps the words clear of the circle.
 *
 * THE CIRCLE IS CENTRED ON THE TITLE'S LINE BOX, not top-aligned with it. The
 * ✕ is 36 tall and the line box is 28 at 375 and about 25 at 320, so a shared
 * top edge leaves the circle hanging low beside its own heading, which both
 * reviews saw at 320 before it was centred. The calc is that line box
 * (--gm-title-size times --gm-title-line, which is why the leading is a named
 * token) less the circle, halved, and it is a NEGATIVE number: the circle is
 * taller than the line of type, so centring lifts it above where the text
 * starts and costs the form nothing, which is the whole reason it is done this
 * way round rather than by pushing the title into the circle's band. */
@media (max-height: 740px) {
  .gm-setup { padding-top: calc(env(safe-area-inset-top, 0px) + 12px); }
  .gm-setup .gm-exit {
    top: calc(env(safe-area-inset-top, 0px) + 12px
              + (var(--gm-title-size) * var(--gm-title-line) - var(--gm-exit)) / 2);
  }
}

/* THE NARROWEST SUPPORTED HANDSET, 320px. One step tighter again, and it is all
   rhythm and box heights: no type comes down here that has not already come
   down at 430, and nothing a thumb lands on is under 44px. A Pro account with
   no payments carries two switch rows, two notices and a hint that a Standard
   account does not, and this is where the four of them are made to fit. */
@media (max-width: 360px) {
  .gm-setup {
    --gm-title-size: calc(22px * var(--font-scale));
    /* THE 44px FLOOR ITSELF, and this is the screen that is allowed to sit on
       it: a 320x568 handset carrying a Pro account with no payments connected
       is the tallest this form ever gets, and every one of these rows is a
       comfortable one-handed target at 44. Nothing goes below it. */
    --gm-row:        calc(44px * var(--density));
    --gm-face:       calc(19px * var(--font-scale));
    /* STILL THE BIGGEST CONTROL ON THE SCREEN by four pixels over every row
       above it, which is the job: at 320 this is the one thing a thumb should
       find without looking. It was 52, and the four pixels went into the gaps
       between the groups, where they buy a form that can be read rather than a
       button that is slightly taller than it needs to be. */
    --gm-go:         calc(48px * var(--density));
  }
  /* ============ THE RHYTHM, AS FAR AS 568 PIXELS WILL ALLOW ============
     The two gaps stay two gaps here: about one to three, as they are on every
     other screen, and never equal. They are simply smaller, because this is
     the screen where a Pro account with no payments needs every pixel it has.
     Measured: the form fills 556 of the 560 this handset leaves it. */
  /* THE PAIR GAP WAS FREE HERE, which was worth saying because nothing else on
     this screen is. The payments note took the difference between the group
     gap and the pair gap as its bottom margin, so the two always added up to
     one group gap: moving the boundary inside that sum changed nothing about
     the height of the state that has a note between the rows, and it showed
     only where there is no note, which is the Pro account with payments
     connected. That sum is broken on purpose now, by --gm-note-pair, which
     this block sets to 4 below: see the token, and the paragraph beside it.

     ============ AND IT GOES TO NOTHING AT THIS WIDTH, 22 SEPTEMBER 2026 =====

     It was 8px, which made the space between the two money rows the largest
     gap in that part of the form: 22.5px against 17 above the pair and 19.5
     below it, so the two rows that are one answer about money read as further
     apart than either reads from the sections they are not part of. Both
     reviews measured it.

     Zero is not the two rows touching. Each is 44px tall around a 30px track,
     so seven pixels of the row's own air sit under the first track and seven
     over the second: measured track to track, a musician sees fourteen pixels
     between them with the boxes flush, against seventeen above the pair and
     seventeen below. That is the smallest of the three, which is what a pair
     should be, and it is the only way to get there at this width without
     widening the group gap, which is spent seven times down the form and would
     cost about fourteen pixels on the one screen that has seven to spare. */
  /* ============ AND THE TALLEST STATE PAYS FOR ITSELF OUT OF THESE =========
     22 September 2026. --gm-label-gap was 3 and --gm-group-gap 10, and in the
     tallest state this form can reach (a Pro account with no payments, so both
     notices are drawn, with a songbook chosen under "My list only", so the
     strict warning is drawn as well) the Go Live button ended 577px down a
     568px screen. The 375px block above buys its 14 pixels back from the ✕'s
     band; this screen has already spent that, and shares the band, so the only
     place left is the rhythm itself.

     WHAT IT COSTS AND WHAT IT DOES NOT. The two gaps are still two gaps and
     the ratio goes the right way, from about one to three to one to four: the
     binding gap is what holds a label to its control, and tighter binds
     harder. Nothing a thumb lands on moves at all, because none of this is
     box height. The group gap is spent seven times down the form and the
     label gap five, which is 19 pixels for two figures, and the button now
     ends 9px clear of the bottom instead of 9px below it. */
  .gm-setup {
    --gm-label-gap:  2px;
    --gm-group-gap:  8px;
    --gm-pair-gap:   0px;
    /* Nothing above a note and six below it. Seven pixels of the row's own air
       already sit between the track and the note, which is a binding gap on
       its own, and the six under the tips note put it 14px from the Gig length
       heading against that 7. It was 13 and 11.5. See --gm-note-gap. */
    --gm-note-gap:   0px;
    --gm-note-after: 6px;
    /* THE GAP UNDER THE PAYMENTS NOTE, WHICH IS NOT A GROUP GAP HERE. The
       default is the group gap less the pair gap, which at this width is the
       whole group gap because the pair gap is zero: the note would sit as far
       from the Tips row as the Tips row's own note sits from the next heading.
       Four keeps the pair a pair in the state that has a note inside it, 12px
       of visible space to the next track against the 7 that bind the note to
       the row above it, and it is four pixels off the tallest state as well.
       See --gm-note-pair, and the tall-handset block, which sets its own. */
    --gm-note-pair:  4px;
  }
  /* ============ THE PAIR'S CONTRAST IS BOUGHT OUTSIDE THE PAIR ============
     22 September 2026, and it is two pixels. The group gap came down to 8 with
     the rest of the rhythm to pay for the tallest state, and the pair gap at
     this width was already nothing, so the two figures closed on each other:
     14px track to track between the money rows against 15 to the heading above
     and below. Correct order, one pixel of it, which no eye reads as a group.
     There is nowhere left INSIDE the pair to take it from, the boxes being
     flush already, so it is taken outside.
     THIS IS THE ONLY GROUP ON THE FORM WITH TWO CONTROLS IN IT. Every other
     section is a heading and one control; the money slot is a heading's worth
     of meaning around two rows that are one answer, so a little more air round
     the outside of it than round a single control is what it is rather than a
     special case. 17 against 14 either side, which is what both reviews
     measured here before the rhythm moved and called correct.
     AN EMPTY SLOT IS STILL NOT A GAP: a Standard account is drawn neither row
     and paintSetupCharging leaves the div in place, so both margins come off
     with the one the base rule already removes. */
  #gmChargingSlot { margin-top: 2px; margin-bottom: calc(var(--gm-group-gap) + 2px); }
  #gmChargingSlot:empty { margin-top: 0; margin-bottom: 0; }
  /* ONE-WORD LABELS, SET AS TIGHT AS ONE-WORD LABELS CAN BE. 1.15 rather than
     the 1.25 above: there are four of them on the form and the leading is
     empty space above and below a single word, which is the cheapest five
     pixels on this screen and the only kind that costs no legibility. */
  .gm-setup .gm-h { line-height: 1.15; }
  /* THE EMPTY VENUE HINT GIVES UP ITS LAST MARGIN TOO. The gap below it is
     the venue section's own, and two margins for one gap is how a form ends
     up with a hole in the middle of it. */
  #gmVenueHint:empty { margin-bottom: 0; }
  /* THE SIX PIXELS AT THE FOOT OF .gm GO BACK. The live screen keeps them: it
     scrolls, and a queue that starts hard against the status bar reads badly.
     This form kept them off because it could not scroll and the last four
     pixels stood between a Pro musician on a 320px handset and a Go Live button
     they could see. It CAN scroll since 24 September and the button is pinned,
     so the six pixels are no longer load bearing; they stay off because this is
     also the distance between the pinned button and the foot of the screen, and
     that is a figure worth keeping the same at every width. The top ones went
     the same way, and the header anchor with them; both are in the max-height
     block above now, which states that treatment once for this screen and the
     375x667 that has just joined it. */
  .gm-setup { padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 4px); }
  /* ============ THE ✕ ENDS WHERE THE FORM ENDS, HERE TOO ============
     22 September 2026, measured by a review: every element on this screen
     lands its right edge on 306.0, the input, the select, the segment, the
     stepper, Go Live and the On/Off word, and the ✕ stopped at 302. The reason
     is that .gm-setup .gm-exit is given `right: 18px`, written out to match
     .gm's own side padding, and the live screen's narrow block takes that
     padding to 14 at this width for both screens. So the one control above the
     fields was the one control off the grid, which is the same four-pixel
     fault that rule was written to fix at 390 and 375.

     STATED, NOT DERIVED, for the same reason 18 is stated: .gm declares its
     padding as a literal and this has to agree with it. If that ever becomes a
     token, both move together.

     ============ AND ITS REACH GOES UP, NOT DOWN ============
     The circle is 36 and every target on this form answers to 44, so the
     ::after that carries the difference adds 4 above and 4 below. Below is now
     where the Venue label starts: the group gap came down to 8 with the rest
     of the rhythm, which puts the label 44.6px down against a hit area ending
     46.3, so a thumb aimed at the top corner of the label would have closed
     the form. The eight pixels go up instead, into the padding above the
     circle, where there is nothing to take a tap from. Six and two rather than
     eight and nothing, so the whole 44 stays on the screen: the circle starts
     6.3px down, and a hit area reaching further than that would be clipped by
     the top edge and would not be 44 in any real sense. */
  .gm-setup .gm-exit { right: 14px; }
  .gm-setup .gm-exit::after { inset: -6px -6px -2px; }
  .gm-setup .gm-switch { gap: 10px; }
  /* Two-line sentences at 12px, tightened from 1.4: at this width both notices
     wrap, so the line height is paid for four times over and 1.25 is still
     open enough to read a wrapped sentence at arm's length. */
  .gm-setup .gm-needs-setup,
  .gm-setup .gm-tips-off { line-height: 1.25; }
  /* THE SEGMENT'S TWO LABELS DO WRAP AT 320px, and this is the cheapest way to
     pay for it. "Only what's on my list" needs about 128px and an equal column
     of this form is 140 less its padding, so the second line arrives however
     the padding is set. Three pixels above and below instead of six puts the
     wrapped button at 36, which is the floor this one control is allowed to
     sit at and the only tap target on the form under 44. It was four, and the
     pair of pixels went to the gaps between the groups for the reason given
     under --gm-go above. */
  .gm-setup .gm-mode-seg button { padding: 3px 6px; }
  .gm-setup .gm-stepper { gap: 8px; }
  /* THE PRO PILL STOPS SETTING THE HEADING'S HEIGHT. It is taller than the
     words beside it, so on the two Pro rows the heading box grew to the pill
     and cost pixels on the screen with none to spare. The mark is not made
     smaller and is not dimmed: it is taken out of the line box, which changes
     nothing anybody can see. */
  .gm-setup .gm-h.pro-heading { line-height: 1.2; }
  .gm-setup .gm-h .pro-pill { line-height: 1; padding-top: 1px; padding-bottom: 1px; }
  /* THE VENUE BLOCK, ON THE SCREEN THAT HAS LEAST TO SPARE. Everything loses
     a few pixels of rhythm and nothing changes shape.

     THE ONE BUTTON PER LINE RULE THAT STOOD HERE IS GONE, 24 September 2026,
     and the reason it existed went with it. It read: two 150px buttons at
     320px leave a pub name showing four letters, and the name is the whole
     content. That was true of a two-column grid whose cells were half the form
     wide whatever they said. A pill is as wide as its name, so at 320px
     "The Vic" is a small pill and "The Bakewell Tap" is a wider one and both
     of them show their whole name. Forcing them to full width now would make
     four short names into four rows for nothing.

     THE RULES FOR .gm-lenrow, .gm-charging-seg AND .gm-tips-switchrow THAT
     STOOD BESIDE THESE ARE GONE, because the 22 September rebuild removed
     the controls they tuned: the length is full width again and both money
     controls are switches. Main's replacements are above. */
  .gm-setup .gm-nearby { margin: 6px 0 8px; }
  .gm-setup .gm-nearby-btn { min-height: 40px; padding: 4px 12px; }
  /* AND A PILL MAY NOT BE WIDER THAN HALF THE ROW HERE, which is the one place
     the cap earns its truncation. At 390px four names wrap on to two rows by
     themselves; at 320px "The Cream Tea Box, Edensor" is 192 of the 292 pixels
     available and four of them become four rows, which put the Go Live button
     58 pixels below the bottom of the world. Half a row each is two rows for
     four pills, always, and a clipped name on the narrowest handset there is
     costs less than a button nobody can reach. The gap is 6px, so 50% less 3
     is exactly two across. */
  .gm-setup .gm-nearby-btn { max-width: calc(50% - 3px); }
  /* THREE ROWS RATHER THAN FOUR, on a 568px screen with a keyboard over half
     of it. The list scrolls either way; this is about how much of the form it
     covers while somebody types. */
  .gm-setup .gm-venue-list { max-height: calc(var(--gm-row) * 3); }
  .gm-setup .gm-newtown { margin-top: 6px; }
  .gm-setup .gm-newtown-input { height: calc(44px * var(--density)); }
  .gm-setup .gm-eventrow { margin-top: var(--gm-note-gap); }
  .gm-setup .gm-event-select { height: calc(40px * var(--density)); }
}

/* ============ THE WHOLE TYPE RAMP COMES DOWN ON A PHONE ============
 *
 * Mark, 3 September 2026: "Look at sizes and how lists are formed in Spotify.
 * We should be able to reduce font sizing to something similar. Including the
 * song title, which seems to be getting truncated fairly often."
 *
 * He is right, and the truncation is the proof. This screen was sized from one
 * argument, that it is read at half a metre off a stand, and that argument is
 * sound and was applied to every string on it. The result is a 17px song title
 * on a 375px card with 145px of room, which truncates on any song with four
 * words in it. A title that reads "Careless Whispe..." at 17px is less legible
 * than one that reads "Careless Whisper" at 15px, whatever the type scale says.
 *
 * ONE OVERRIDE OF THE RAMP, NOT A RULE PER ELEMENT. Everything on this screen
 * already sizes off --gm-t-*, which is what that ladder is for, so a phone gets
 * smaller type everywhere by redefining six values in one place. Nothing here
 * touches --gm-space, --gm-card-pad or --gm-control: the rhythm and the tap
 * targets are a separate decision and they stay.
 *
 * THE THEME MULTIPLIER STILL APPLIES. Each of these keeps its
 * calc(px * var(--font-scale)), so Stage arrives bigger than Dark here exactly
 * as it does everywhere else, which is the whole point of that token. */
/* ---- live ---- */
.gm-live { position: relative; }

/* Two rows on a phone, one on anything wider. Never three: the pieces inside
   .gm-bar-clock are dropped by the media queries at the foot of this section
   in order of how little they matter, rather than being allowed to wrap. */
.gm-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px 14px;
  padding-bottom: var(--gm-space);
  border-bottom: 1px solid var(--border);
  margin-bottom: var(--gm-space);
}
.gm-bar-status {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 0;
  flex: 1 1 100%;
  /* Clears the absolutely positioned ✕. */
  padding-right: var(--gm-exit-clear);
}
.gm-bar-clock {
  display: flex;
  align-items: center;
  gap: 14px;
  flex: 1 1 100%;
  min-width: 0;
  font-size: var(--gm-t-mid);
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  /* The ✕ is absolutely positioned, so on two rows it hangs over this one as
     well as the status row above. Both have to clear its full footprint or the
     countdown slides under it. */
  padding-right: var(--gm-exit-clear);
}
.gm-live-dot {
  width: 12px; height: 12px; border-radius: 50%;
  background: var(--positive);
  flex: none;
  animation: livePulse 1.8s ease-in-out infinite;
}
.gm-live-label {
  font-weight: var(--weight-strong);
  letter-spacing: var(--tracking-wide);
  font-size: var(--gm-t-body);
  color: var(--positive);
  flex: none;
}
/* The one thing allowed to shrink, because it is the one thing of unbounded
   length. A long pub name truncates instead of shoving the clock off screen. */
.gm-venue {
  color: var(--text-secondary); font-size: var(--gm-t-body);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  min-width: 0; flex: 0 1 auto;
}
/* Start and End: a quiet word, then the figure. Same treatment for both so
   they read as a pair, and neither can be mistaken for the live clock. */
.gm-tpair { display: inline-flex; align-items: baseline; gap: 5px; flex: none; }
/* Was --text-secondary at 80% opacity. Knocking the quiet role quieter still
   is exactly the "emphasise with brightness" move the theme rules forbid, and
   it costs legibility at half a metre for nothing; size and tracking already
   separate a label from its figure. */
.gm-tlabel {
  font-size: var(--gm-t-micro);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-secondary);
}
.gm-tval { color: var(--text-primary); font-size: var(--gm-t-mid); }

/* The clock. The only figure on the bar that moves, so it gets a face, a pill
   and the accent colour: at half a metre it should be findable without
   reading anything.
 *
 * The pill is an OPAQUE tint mixed into the page background, not the accent at
 * 16% alpha. Alpha over an unknown backdrop gives a different separation on
 * every screen, and this pill has to stay findable under a coloured wash. */
.gm-clock {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  flex: none;
  padding: 3px 11px 3px 8px;
  border-radius: 999px;
  /* An opaque plane off the ladder rather than an accent wash. At 16% over the
     page this came out pale lilac on Light — a pastel accent, which is the
     failure mode light.js names. The clock is a reading surface, not an
     action, so it takes a surface and accent-coloured numerals. */
  background: var(--surface-raised);
  border: 1px solid var(--border-strong);
  color: var(--action-on-surface);
  font-size: var(--gm-t-mid);
  font-weight: var(--weight-strong);
  font-variant-numeric: tabular-nums;
}
.gm-clock-face {
  width: 15px; height: 15px; flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
}
/* THE THREE CLOCK TIMES ARE ONE GROUP ON THE LEFT, AND THIS REVERSES LAST
   SESSION'S REASONING.
 *
 * That reasoning was: two fixed facts together, two about the future together
 * on the right. It sounded tidy and it was wrong. Mark reads the row as three
 * clock times — when it started, what time it is, when it ends — and one figure
 * that is not a clock time at all. So Start, the chip and End sit together on
 * the left at the row's own gap, evenly spaced, and only the countdown goes
 * right.
 *
 * The auto margin is therefore back on the countdown, where it was before, and
 * off .gm-t-end. */
.gm-remaining {
  display: inline-flex;
  align-items: baseline;
  gap: 5px;
  font-size: var(--gm-t-large);
  font-weight: var(--weight-strong);
  color: var(--text-primary);
  flex: none;
  margin-left: auto;
  /* AND A FLOOR UNDER THE SPLIT, because `margin-left: auto` resolves to zero
     the moment the row is full. At 375 it did exactly that and the gap between
     the three times and the countdown measured 10px — identical to the gaps
     INSIDE the group, so the split that this whole arrangement exists to make
     stopped existing at the width it matters at. What rescued it was 14.7px of
     dead space left inside the button by an unrelated min-width rule, which is
     luck rather than design. */
  padding-left: 16px;
  font-variant-numeric: tabular-nums;
}
/* "43m left" rather than a bare 43m, so the countdown cannot be read as
   another wall-clock time. */
.gm-remaining-word {
  font-size: var(--gm-t-micro);
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-secondary);
}

/* The two headline numbers, spelled out. Fixed with the bar: they must stay
   put while the queue scrolls beneath. */
.gm-counts {
  display: flex;
  flex-wrap: wrap;
  gap: 6px calc(var(--gm-space) * 1.8);
  padding: calc(var(--gm-space) * .8) 0 var(--gm-space);
  border-bottom: 1px solid var(--border);
  font-size: var(--gm-t-body);
  color: var(--text-secondary);
}
.gm-stat b {
  font-size: var(--gm-t-large);
  font-weight: var(--weight-strong);
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
}
.gm-stat.is-queue b { color: var(--action-on-surface); }

/* THE SLOTS THE LIVE SCREEN IS PAINTED INTO, and they are invisible on
   purpose. Gig Mode is six regions painted separately, so a request arriving
   repaints the counts and one card rather than the whole screen — see the note
   above paintLive in js/views/gigmode.js. Each region needs a stable element to
   be painted into, and `display: contents` means those elements take part in no
   layout at all: .gm is a flex column and .gm-scroll a block, and both see
   exactly the children they saw before the slots existed. */
.gm-slot { display: contents; }

/* One scrolling region between the fixed header (bar + counts) and the fixed
   sheet. The QUEUE is what scrolls; the numbers a musician glances at mid-song
   never move. */
.gm-live { height: 100vh; height: 100dvh; overflow: hidden; }
.gm-scroll {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  padding-top: var(--gm-space);
  /* Clears the sheet handle so the last card and End gig are reachable. */
  padding-bottom: calc(var(--gm-space) * 1.4);
}
.has-sheet .gm-scroll {
  padding-bottom: calc(var(--gm-sheet-handle) + env(safe-area-inset-bottom, 0px) + var(--gm-space));
}
.gm-queue { display: grid; gap: var(--gm-space); align-content: start; max-width: 900px; margin: 0 auto; width: 100%; }
.gm-scroll .gm-panel, .gm-scroll .gm-footer { max-width: 900px; margin-left: auto; margin-right: auto; width: 100%; }

/* What became of this song earlier in the SAME gig. It sits in the meta line,
   styled as a tag so it cannot be read as part of the song title.
 *
 * Played and Skipped are two categories, so they get a marker as well as a
 * tint: FILLED disc for played, HOLLOW for skipped. That survives greyscale, a
 * dichromat and a red LED wash on its own; the colour is the third cue after
 * the marker and the word inside the chip. */
.gm-prior {
  display: inline-flex;
  align-items: center;
  gap: .45em;
  /* No margin: it is a flex item on the card's bottom row and the row's own
     gap separates it from the names. Nothing inside this card carries one. */
  white-space: nowrap;
  padding: 1px 8px;
  border-radius: 999px;
  border: 1px solid var(--border);
  font-size: var(--gm-t-micro);
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}
.gm-prior::before {
  content: '';
  flex: none;
  width: .58em; height: .58em;
  border-radius: 50%;
  border: 1.5px solid currentColor;
}
/* Opaque tinted planes from the token set rather than an alpha wash over
   whatever happens to be behind. */
.gm-prior.is-played {
  color: var(--negative-text);
  border-color: var(--negative);
  background: var(--negative-surface);
  font-weight: var(--weight-strong);
}
.gm-prior.is-played::before { background: currentColor; }
/* Had no rule at all, despite js/views/gigmode.js emitting the class: a song
   skipped earlier tonight looked identical to a neutral chip. */
.gm-prior.is-skipped {
  color: var(--notice-text);
  border-color: var(--notice);
  background: var(--notice-surface);
}
.gm-prior.is-skipped::before { background: transparent; }

/* ================= THE CARD IS A GRID, AND THE PLACES ARE NAMED =================
 *
 * "Title and artist on the left, buttons on the right, one row. Requester
 *  names go underneath, as pills. If all three buttons plus the title
 *  genuinely do not fit at a given width, move Chords to the second row
 *  alongside the names, keeping it small. Do not let it push Played and Skip
 *  down."
 *
 * Three columns and two rows, and the only thing that changes with width is
 * which cell two of the four items sit in. WIDE, on a tablet or a laptop:
 *
 *     line   chords   PLAYED SKIP        the controls span both rows, on the
 *     names  names    PLAYED SKIP        right, level with the title
 *
 * NARROW, on a phone, where all three controls plus a title do not fit on one
 * row — the case Mark describes:
 *
 *     line   line     PLAYED SKIP        Chords drops, on its own, to the
 *     names  names       chords          bottom row beside the names
 *
 * WHY THE CONTROLS SPAN WHEN THEY CAN. Played and Skip are 52px on a tablet;
 * the title line is 26px. Sitting IN the top row they make it 52px tall and
 * the names are stacked below that — 105px of card to show 56px of content,
 * with the left half of the top row empty air above the names. Spanning puts
 * the same controls on the same right-hand edge and lets the two text rows set
 * the height: 82px instead of 105, which is two or three more requests on
 * screen for no change to anything you read or hit.
 *
 * AND WHY THEY DO NOT SPAN ON A PHONE. A spanning block owns its column on
 * BOTH rows, so Chords has to take a column of its own — and on a 339px card,
 * once Played and Skip have 144px and Chords 100px, the names are left with
 * 47px. Measured, and it renders as "for B". The names are the thing Mark
 * asked to see underneath; a row that cannot hold one name is not showing
 * them. So on a phone the controls take the top row only, Chords sits at the
 * right of the bottom row underneath them, and the names get the rest of it.
 * That costs about 20px of height on the two phone widths and buys back a
 * readable card.
 *
 * WHY NOT FLEX-WRAP. Flex wraps whichever item runs out of room LAST in source
 * order, which is Played and Skip, and no amount of `order` fixes it:
 * reordering moves the break along with the item. Grid names the cell that
 * moves, so the two controls that matter never do.
 *
 * ONE SPACING SCALE, DECLARED ONCE. Every gap and pad inside the card comes
 * from the five properties below and nothing in here carries a margin. They
 * had drifted to eleven separate values — 4px here, 6px there, 8px/10px of
 * padding — which is why "the spacing is off throughout" was the right
 * description and why no single value was the thing to change. */
.gm-card {
  /* Two rows of text, so the vertical padding is tighter than the horizontal:
     the row gap already separates them and 12px top and bottom would read as a
     box round a paragraph rather than a row in a list.
     SIX, NOT NINE, AND THE THREE PIXELS WENT TO THE ROW GAP — see below. The
     card's height is unchanged at every width because the two moved together:
     3px off the top and bottom, 6px added between the rows. Padding at the
     card's edge separates a row from the row above it, which the list's own
     gap already does; the gap between the two rows separates a control from a
     tap target, which nothing else does. */
  --card-pad-x: 12px;
  --card-pad-y: 6px;
  /* The title and the names are ONE fact about ONE song, and 6px kept them a
     block where 12px read as two things that happen to share a border. It went
     to 12 anyway, because on a phone this gap is not typography — it is the
     only thing standing between Played and two tap targets underneath it.
     MEASURED AT 375 AND 430, with the old 6px: the Chords button sat 6px below
     Played and the names row 8px below it, and the names row runs 47px UNDER
     Played because it spans the spacer column the actions are placed against.
     A thumb reaching for the names in a dark room, 8px low of a 44px green
     button, marks the song played in front of the room. That is the mis-tap
     this variable was already written to prevent — the note here used to say
     so, about Chords, and it had the number too low to do it.
     Now 12px, giving 14px to the names row and 12px to Chords.
     IT COSTS NO HEIGHT AND NO CARDS. The six pixels come straight out of
     --card-pad-y above rather than out of the card: 100px at 375 and 430 and
     78px at a tablet, the same before and after, so the same four, six and
     nine cards fit. Only the distribution inside the card changed.
     ONE VALUE AT EVERY WIDTH still, and now for a second reason: .gm-card is
     the container these queries are named against, and a container cannot
     restyle itself — there is no @container branch that can give the phone a
     different number, and reaching for a viewport media query to do it is the
     mismatch documented against `container: gmcard` below. */
  --card-row-gap: 12px;
  /* Text to controls. The only gap on the card that has to survive being
     glanced at from a metre away, so it is the largest. */
  --card-col-gap: 12px;
  /* Between the things on the title line, and between the name pills. The
     pills carry their own border so they need less air to separate. */
  --card-inline-gap: 7px;
  --card-pill-gap: 5px;
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto;
  grid-template-rows: auto auto;
  align-items: center;
  gap: var(--card-row-gap) var(--card-col-gap);
  padding: var(--card-pad-y) var(--card-pad-x);
  border-radius: var(--radius);
  background: var(--surface);
  border: 1px solid var(--border);
  /* NAMED, AND THAT IS NOT DECORATION. css/dashboard.css puts
     `container-type: inline-size` on section[data-view], so an unnamed
     @container is resolved against whichever of the two is NEAREST — and the
     nearest for `.gm-card` is the section while the nearest for `.gm-played`
     inside it is the card. One block written against "min-width: 26rem" moved
     the grid at a 430px VIEWPORT and the buttons at a 430px CARD, which are
     ninety pixels apart: the card flipped to the wide layout while the buttons
     stayed phone-sized, and the title was left with 102px and rendered as "S.".
     Naming it means every rule below is measured against the same box. */
  container: gmcard / inline-size;
}
/* PLACED BY LINE, NOT BY AREA, because `grid-template-areas` lives on the card
   and a container query cannot restyle its own container — so the wide layout
   has to be expressible as changes to the CHILDREN. Two of them move; the
   other two never do. */
/* THE MIDDLE COLUMN IS A SPACER THE ACTIONS PAY FOR, and it is what lets the
   two rows have different column boundaries on a phone — which they need,
   because the top row wants to break at the left edge of Played and the bottom
   row at the left edge of Chords, and those are 60px apart.
   Written first with Chords sharing the actions' column, the names row stopped
   at the actions' left edge and left 84px of empty card between a name chopped
   in half and the button: "a quarter of the screen sitting empty on the row
   where a name is being cut". Giving Chords a column of its OWN width, and
   letting the actions span that column and the spacer beside it, puts every
   edge where it belongs and hands the names row those 84px back. */
.gm-line { grid-column: 1; grid-row: 1; }
/* THE BOTTOM ROW: what happened to this song earlier tonight, then who asked
   for it. Two facts about the REQUEST, under the one line that says which song.
   The chip is flex:none and the names take what is left, so a long queue of
   names never squeezes "PLAYED 21:22" into an unreadable sliver — which is
   exactly what it did when it sat on the title line. */
.gm-under {
  grid-column: 1 / 3;
  grid-row: 2;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: var(--card-inline-gap);
  /* NEW and the PLAYED chip are flex:none, so without this they would paint
     themselves straight across whatever is in the cell next door. */
  overflow: hidden;
}
.gm-under .gm-new, .gm-under .gm-prior { flex: none; }
/* ON A PHONE, A REPLAY CARD DROPS THE NEW BADGE AND NOTHING ELSE.
 *
 * Its bottom row is 219px wide on a 375px card and "● PLAYED 00:56" is 135px
 * of it. That leaves 77px — enough for "for Mel", not enough for a 38px badge
 * as well, and a row that tries to carry all three ends with a name cut to one
 * faded letter: "it reads as a rendering speck, not as content".
 *
 * NEW is the one to lose, and losing it loses no information: a new card is
 * already lifted a rung up the surface ladder and outlined for it — see
 * .gm-card.is-new — while who asked has nothing else saying it. Both are back
 * at 480px, where the row is wide enough to hold everything.
 *
 * The TIME was the other candidate and it was tried. Dropping it left a red
 * "PLAYED" beside a green "Played" on the same card with nothing to tell them
 * apart at arm's length in a dark room. */
.gm-card.is-replay .gm-new { display: none; }
/* FILLS THE ROW EVEN WHEN THE NAMES DO NOT, which is what keeps the fade below
   honest: masking the last 44px of a box that has shrunk to fit its content
   fades the last NAME on every card, including the ones with room to spare. */
.gm-under .gm-for { flex: 1 1 auto; }
/* Bottom right on a phone, under Played and Skip rather than between them and
   the names — it is the only place on that row wide enough to hold it without
   taking the names' last hundred pixels. Its own column, sized to it. */
.gm-chart-go { grid-column: 3; grid-row: 2; justify-self: end; }
.gm-actions { grid-column: 2 / 4; grid-row: 1; }
/* NEW and REPLAY are separate facts and a card is routinely both, so they use
   separate devices: NEW takes the outline, REPLAY takes a left-edge rail. They
   used to be two border-colour rules, which meant the later one silently won
   and a card that was both lost the NEW border entirely. Both now show at
   once, and the rail is a position cue that reads with the colour gone. */
/* NEW lifts the card one rung up the surface ladder rather than outlining it
   in the accent. At the start of a gig EVERY request is new, so the outline
   version drew the accent around every card on the screen — the accent as the
   container, which is the pattern docs/THEMING.md rejects and the exact thing
   dark.js's post-mortem says violet must stop doing ("stays, but as the ACTION
   colour only... rather than being the separator"). A lift says the same thing
   with luminance, which is what the ladder is for, and it still reads with the
   colour gone. The NEW badge carries the word. */
.gm-card.is-new {
  background: var(--surface-raised);
  border-color: var(--border-strong);
}
/* A song that has already been round tonight. Red rather than green because
   green reads as done-and-fine, and the whole point of the card is that it is
   back and needs a decision. The "PLAYED 21:14" chip in the meta line says the
   same thing in words. */
.gm-card.is-replay {
  border-left: 6px solid var(--negative);
  /* Keeps the title on the same left edge as every other card, so the rail
     reads as an addition to the card rather than a shove. Off the card's own
     padding, so it stays true if that changes. */
  padding-left: calc(var(--card-pad-x) - 5px);
}
/* ================= THE TITLE LINE =================
 *
 * Title, artist, ×N and NEW, in that order, on one line that never wraps and
 * never grows. Everything else about the card was argued from this line
 * staying one line high.
 *
 * `align-items: baseline` sits the 14px artist on the same baseline as the
 * 20px title, which is what makes them read as one phrase rather than as two
 * things in a row. */
.gm-line {
  min-width: 0;
  display: flex;
  align-items: baseline;
  gap: var(--card-inline-gap);
  /* THE LAST DEFENCE, and it should never be reached: if a card ever does run
     out of room with every floor below honoured, the overrun is clipped inside
     the card instead of being painted over the buttons. That is not
     theoretical — the PLAYED chip used to do exactly that on a 375px screen,
     spilling a red sliver of itself across Played and Skip. */
  overflow: hidden;
}
/* WHAT GIVES WAY FIRST, AND HOW FAR.
 *
 * The old rule was "the title truncates and the artist does not" — the artist
 * `flex: none`, the title `min-width: 0`. On a 1194px iPad that is right and
 * costs nothing. On a 375px phone it was catastrophic: with Played and Skip on
 * the same row the line had about 160px, the artist and the two badges are
 * `flex: none` and wanted all of it, and the title — the only thing allowed to
 * shrink, with no floor — WENT TO ZERO. Measured on the fixture: ten cards at
 * 375px, and the first read "Billie Eilish ×10 NEW". Not one character of the
 * song. A title truncated to nothing is not a truncated title.
 *
 * The answer is two different treatments, because the right one depends on
 * whether there is room for both at all:
 *
 *   WIDE — two boxes. The artist never shrinks and the title takes the whole
 *   truncation. "Wonderwa… Oasis" still says which song; "Wonderwall Oa…" does
 *   not, and on a queue where three covers of one title turn up in a night the
 *   artist is the half that disambiguates. `max-width: 45%` stops a long act
 *   name taking the title's room in the first place.
 *
 *   NARROW — ONE RUN OF TEXT with one ellipsis, see .gm-title. On a 375px card
 *   the line is 159px and there is not room for both, so as two boxes whatever
 *   was left over after the title landed on the artist and rendered as a
 *   fragment: measured, "Dancing Que… A…" and "Mr. Brightsi… The …" — two
 *   ellipses on one line and neither half readable. As one run the phrase
 *   truncates the way a phrase does and the artist is either there or it is
 *   not.
 *
 * NO min-width ON THE TITLE, and that was tried. A floor stops it vanishing,
 * but `min-width` is also a floor on SHORT titles: "Valerie" got a 4.5em box
 * and sat marooned four characters away from "Amy Winehouse".
 *
 * ×N never shrinks: it is why the card is where it is in the queue. */
/* THE WRAPPER THAT IS ONLY A WRAPPER ON A PHONE. Above 480px it is
   `display: contents`, so the song and the act are flex items of .gm-line and
   every rule below applies to them exactly as written. Below it, it is the box
   that holds them as one line of text and carries the single ellipsis; the
   `overflow`/`text-overflow`/`flex` on .gm-song and .gm-artist have no effect
   on an inline box and quietly stand down. */
.gm-title {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  /* THE STRUT IS THE SONG'S, not the card's. As a box it gets a line box of its
     own, and inheriting the app's 1.5 paragraph leading off the card's own font
     size made every phone card 7px taller — the whole queue lost a request for
     a line-height nobody chose. */
  font-size: var(--gm-t-song);
  line-height: 1.25;
  /* AND THE ELLIPSIS IS THE ARTIST'S COLOUR, NOT THE SONG'S, because this box
     paints it and the song only ever fills part of the box. "Everything I
     Wanted…" is a COMPLETE song title with a dropped artist behind it, and in
     the title's white the three dots looked like the name of the song carrying
     on — "on this screen that is a real misread". In the artist's grey they
     read as what they are: the quieter half of the line, cut. */
  color: var(--text-secondary);
}
/* "NOBODY NAMED A SONG", ON THE TITLE LINE.
 *
 * An act request has no title, so the card prints the act's name where a song
 * would go. Without a mark it reads as a song the musician has never heard of,
 * and what they do about it is different: they pick something themselves.
 *
 * A WORD RATHER THAN A COLOUR. The queue is read at a glance from a stage,
 * frequently under a coloured wash that no palette survives, and the count
 * badge beside it is already a small tinted pill meaning something else. Two
 * pills in different colours on one line is two things to learn.
 *
 * flex-shrink: 0, so the tag never truncates. The title beside it may, and
 * "The Beat…" with a visible ACT tag is legible; "AC…" with a full title is
 * not. It is also, deliberately, the one thing on the line that cannot be
 * mistaken for part of the name. */
.gm-act-tag {
  flex: 0 0 auto;
  align-self: center;
  margin-right: 6px;
  padding: 1px 5px;
  border: 1px solid var(--text-secondary);
  border-radius: 3px;
  color: var(--text-secondary);
  font-size: 0.6875rem;
  font-weight: var(--weight-strong);
  letter-spacing: 0.04em;
  line-height: 1.4;
  /* Outline rather than fill: .gm-count is a filled pill, and the two must not
     look like the same kind of object. */
  background: none;
  white-space: nowrap;
}
.gm-song {
  flex: 0 1 auto;
  min-width: 0;
  /* Stated, not inherited: .gm-title deliberately holds the artist's grey so
     the ellipsis it paints is the artist's — see the note there. */
  color: var(--text-primary);
  font-size: var(--gm-t-song);
  font-weight: var(--weight-strong);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* HOW MANY PEOPLE ASKED, ON THE TITLE LINE. "That is the number of people who
   asked and it drives the queue order" — so it sits with the song it belongs
   to, it never shrinks, and it never truncates. It is no longer a tall
   inline-block inside the title's own line box, which is what used to make a
   one-line title 43px high; it just stops setting the line height.
   7px of padding rather than 8: on a phone this badge and NEW together are the
   difference between a title of eight characters and one of six. */
.gm-count {
  flex: none;
  padding: 0 7px;
  border-radius: 999px;
  background: var(--action);
  color: var(--action-text);
  font-size: var(--gm-t-body);
  font-weight: var(--weight-medium);
  font-variant-numeric: tabular-nums;
  line-height: 1.45;
}
/* ON A PHONE IT IS NOT ON THIS LINE AT ALL.
 *
 * "At 375px the title gets 145px and four of ten test titles truncate before
 * they are readable at arm's length. Reading WHICH SONG was asked for matters
 * more than seeing the count on the same line."
 *
 * The badge is 48px of those 145 — a third of the box, on the one row the card
 * exists to deliver. It moves to the row underneath, six pixels away, where
 * the rest of what-this-request-is already lives; and it moves back up at
 * every width from 480px of card, where the line has room for both and having
 * the count beside the title is worth more than the pixels it costs.
 *
 * CSS CANNOT REPARENT A NODE, so the card writes the badge into both rows and
 * exactly one of the two is `display: none` at any width — see cardInner() in
 * js/views/gigmode.js. This is the phone half: the copy on the title line is
 * off, the copy on the row below is on. The container query at the foot of
 * this section swaps them, and it has to be BELOW this rule to do it: a
 * container query adds nothing to specificity, so the later declaration wins
 * outright and a block placed above this one would silently lose. */
.gm-count.is-inline { display: none; }
/* It shares the row with the NEW badge, the PLAYED chip and the names, and it
   is the only one of the four that must never give ground: it is why the card
   is where it is in the queue. The names are the flexible one. */
.gm-under .gm-count { flex: none; }
/* A square-cornered block against the round ×3 pill: same accent family,
   different shape, so the two badges never read as the same kind of thing.
   IT SITS ON THE BOTTOM ROW, at the head of the line that says what this
   REQUEST is — new, already played earlier, and who asked. The top line says
   which SONG, and on a 375px card this badge was taking 45px of the 159 that
   line has: with it there the queue read "Ever…", "Wond…", "Sw…", and without
   it the same cards read "Everything…", "Wonderwall", "Sweet Car…". The card
   itself also lifts a rung when it is new, so nothing about the fact rests on
   the badge's position. */
.gm-new {
  flex: none;
  padding: 0 6px;
  border-radius: 6px;
  line-height: 1.7;
  background: var(--action-on-surface);
  color: var(--action-text);
  font-size: calc(10px * var(--font-scale));
  font-weight: var(--weight-strong);
  letter-spacing: var(--tracking-wide);
  vertical-align: middle;
}
/* BESIDE THE TITLE, NOT UNDER IT. It had its own line, argued from "the title
   is what gets read at half a metre". True, and it cost a line on every card in
   the queue for a fact that is three words long. Which song it is is ONE fact;
   splitting it over two lines is what made the queue a stack.
   It is a flex item only above 480px, where .gm-title stops being a box — see
   the container query at the foot of this section for the size rules that go
   with that. Below it, it is plain inline text at the end of the run, and the
   padding is what separates it from the song. A space character there is a
   character the ellipsis can land after: a title that fitted exactly and an
   artist that did not came out as "Wonderwall  …  ×3", three grey dots standing
   for nothing. Padding is not text. */
.gm-artist {
  min-width: 0;
  padding-left: 5px;
  color: var(--text-secondary);
  font-size: var(--gm-t-body);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* ============ ON A PHONE THE ACT IS ON THE ROW BELOW ============
 *
 * Measured on a 375px phone rather than guessed at, with one name on the card:
 *
 *   act beside the title   the title truncates: "Careless Whispe..."
 *   act on the row below   .gm-for is squeezed to ZERO and "for Cara" becomes
 *                          the "1 person" chip that stands in when no pill fits
 *   act nowhere            the title reads in full and the name stays
 *
 * THE ROW BELOW IS THE ONE THAT WAS CHOSEN, and it has a cost that is written
 * here rather than discovered later. On a card that ALSO carries a name, the
 * names row loses its fade allowance and "for Cara" becomes the "1 person"
 * chip that stands in when no pill fits. That is not a width that can be tuned
 * around: .gm-for needs its content plus the 44px fade to show one pill, and
 * once the NEW badge and Find chords have theirs the row has less than that to
 * give. Capping the act at 70px and then at 56px was measured and changes
 * nothing.
 *
 * SO IT IS A TRADE AND MARK MADE IT, 3 September 2026: "Removing the act name
 * completely is worse than anything else. We want to know artists." Knowing
 * which recording beats knowing which of the people on a card typed a name,
 * and the name is one tap away in the panel while the act would not be. Cards
 * with no name at all, which is most of them, lose nothing.
 *
 * WHAT THE OTHER ARRANGEMENTS COST, all measured on a 375px card:
 *   beside the title, inside .gm-title   the box truncates as one, so the card
 *                                        reads "Careless Whisper..." and the
 *                                        act is clipped away entirely
 *   beside the title, as its own item    both truncate: "Careless ..." and
 *                                        "George ...", five titles of eight
 *   not on the card                      title and name whole, no act */
.gm-artist.is-inline, .gm-artist.is-row2 { display: none; }
/* SMALLER, AND THAT IS THE POINT OF PUTTING IT HERE. It has to drop into a row
   that already exists without making the row any taller, so it takes the same
   size as the badges beside it rather than the title's. The act is secondary
   and reads as secondary; the song above it is the thing being found. */
.gm-artist.is-row2 {
  /* LAST IN THE ROW AND FIRST TO SHRINK. Who asked is what this row is for; the
     act is a reminder of which recording. Placed before the names it took a
     third of the row and "for Cara" collapsed into the "1 person" chip, so the
     card gained an act and lost a person. It goes after them and takes what is
     left, which is the gap that was sitting there anyway. */
  /* SHRINK FACTOR 100 AGAINST THE NAMES ROW'S 1, which is the whole of making
     this safe. Flex distributes a shortfall in proportion to each item's shrink
     factor, so on a crowded row this gives up a hundred pixels for every one
     the names give up: it is squeezed to nothing, and only then does anything
     else lose a pixel. Placed last and shrinking first, it can take the gap
     when there is one and vanish when there is not, without a measurement and
     without a class to keep in step. */
  flex: 0 100 auto;
  min-width: 0;
  padding-left: 0;
  font-size: var(--gm-t-micro);
  line-height: 1.25;
  /* Never more than a third even when the row is empty, so it cannot start
     reading as the subject of the row it is sitting at the end of. */
  max-width: 33%;
}

/* WHO ASKED. A row of pills that wraps, not a comma-separated run of text.
 *
 * Read from about half a metre, off an iPad on a mic stand, in a room lit for
 * the audience. "Sarah, Tom, Dave, Jen" at that distance is one long shape and
 * nobody parses it between songs; four separate pills are four shapes and the
 * count is available without reading a word of it.
 *
 * flex-wrap rather than any fixed height, so the card grows to fit and nothing
 * is clipped. js/views/gigmode.js caps the set at eight and turns the rest into
 * a "+3" chip — the same overflow pattern that was already here — so a card can
 * grow by a row or two but never take the whole screen. */
/* ONE ROW, AT EVERY WIDTH, AND IT FADES RATHER THAN CUTTING.
 *
 * The pills used to wrap once the card was wide enough, and a card with ten
 * people on it grew a second line of them — 114px against the 82px of every
 * other card in the queue on the same screen. A queue whose rows change height
 * according to how many people happened to ask for a song is a queue you
 * cannot learn the shape of, and height is the whole complaint. So they stay
 * on one line and the row fades out where it runs out.
 *
 * NOTHING IS LOST BY FADING, and that is why this is the right trade rather
 * than a resigned one: how many people asked is already on the title line as
 * ×10, in the accent, and it is the number the queue is ordered by. The names
 * are who, not how many.
 *
 * THE FADE IS THE FIX FOR THE HARD CUT. `overflow: hidden` alone sliced the
 * last pill down the middle — "Siob" on a 375px screen, which reads as a
 * rendering fault rather than as "there are more". The mask takes the last
 * 28px down to nothing, so a clipped pill reads as continuing. It costs
 * nothing when everything fits: there is only card behind it, and card faded
 * into card is invisible. */
.gm-for {
  min-width: 0;
  color: var(--text-secondary);
  font-size: var(--gm-t-body);
  display: flex;
  align-items: center;
  gap: var(--card-pill-gap);
  flex-wrap: nowrap;
  overflow: hidden;
  /* AND THE TEXT INSIDE THE PILLS DOES NOT WRAP EITHER, which is the half that
     actually cost the height. Stopping the ROW from wrapping squeezed .gm-for
     to 62px, and .gm-name-pill carries max-width:100% — so the pill obediently
     shrank and its name wrapped INSIDE it. One name became a 47px two-line pill
     and the card was three rows again. Measured: 133px on the card with two
     names, against 87px on the one whose name happened to be short. */
  white-space: nowrap;
  /* 44px, not 28. At 28 a pill whose last few pixels reached the edge came out
     as a hard little stub — "a ~4px orphaned pill fragment, it looks like a
     paint bug" — because the fade was shorter than the thing it was softening.
     Over 44px a clipped pill dissolves, and a names row too narrow to hold
     anything at all reads as empty rather than as a rendering fault. */
  -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 44px), transparent);
  mask-image: linear-gradient(to right, #000 calc(100% - 44px), transparent);
}

/* "for" is the label for the set, not a member of it, so it stays plain text
   and takes no chip. */
.gm-for-label { flex: none; color: var(--text-secondary); }

/* NO NEW COLOURS. --surface-sunken is the recessed tier every app theme
   already defines against a card, and --text-primary is the same ink the song
   title uses. That pairing is what makes a name legible at arm's length while
   still reading as quieter than the title above it: the contrast comes from
   the plate behind it rather than from shouting the text.
   The border is the one the themes give for a raised edge; on Stage, where the
   palette is nearly monochrome, it is what stops the pills merging into one
   another under low light. */
.gm-name-pill {
  display: inline-flex;
  align-items: center;
  max-width: 100%;
  padding: 2px 9px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--surface-sunken);
  color: var(--text-primary);
  font-size: var(--gm-t-body);
  font-weight: var(--weight-medium);
  line-height: 1.45;
  /* A name is 24 characters at most (enforced on the request page and in the
     database), so it will not run away — but a single unbroken 24-character
     string still has to be able to sit inside a card on a narrow phone. */
  overflow-wrap: anywhere;
}

/* HOW MANY PEOPLE THE ROW IS NOT SHOWING, AND IT IS OUTSIDE THE FADE.
 *
 * There was a chip for this and it was the last item INSIDE .gm-for — so the
 * mask that softens the end of that row took it first. Measured on a
 * fifteen-name card at all seven widths this screen is used at, from a 375px
 * phone to a 1440px laptop: not visible at one of them. The only element on
 * the card whose job was to say "there are more people than these" was the one
 * element guaranteed to be faded out.
 *
 * Out here it is a sibling of the names, `flex: none`, laid out after the row
 * rather than in it. The mask cannot reach it and the row cannot push it off
 * the end: .gm-for takes what is left and the count keeps its width.
 *
 * ITS NUMBER IS MEASURED, NOT COUNTED. The old chip said how many names were
 * past the render limit of eight, which on a phone was almost always zero
 * while the row was in fact showing two of them. markHiddenNames() in
 * js/views/gigmode.js counts the ones the FADE hides as well, so "+13" means
 * thirteen people whose names are not on this screen.
 *
 * IT LOOKS PRESSABLE BECAUSE IT IS. Tapping it — or anywhere on either row of
 * the card's text — opens the panel with all of them on it. So unlike the chip
 * it replaces, this one takes the raised plane and the strong border rather
 * than going transparent and quiet: it is the visible affordance for the whole
 * peek, and the one thing on the card that says the names can be got at. */
.gm-more {
  flex: none;
  display: inline-flex;
  align-items: center;
  padding: 2px 9px;
  border-radius: 999px;
  border: 1px solid var(--border-strong);
  background: var(--surface-raised);
  color: var(--text-primary);
  font-size: var(--gm-t-body);
  font-weight: var(--weight-medium);
  font-variant-numeric: tabular-nums;
  line-height: 1.45;
  white-space: nowrap;
}
/* IT TAKES NO TAP OF ITS OWN, and that stopped being cosmetic on 28 September.
   This is aria-hidden decoration saying how many people the row is not
   showing, and it sits in the same row as the pills, so on a crowded card it
   can land on top of one. While a pill did nothing on its own that cost
   nothing. Now a pill opens the person, a tap swallowed here is a tap that
   silently does the wrong thing, so it passes straight through to whatever is
   underneath. Found by a click in tests/live-identifiers.mjs that could not
   reach the first pill on a two-person card. */
.gm-more { pointer-events: none; }
/* The attribute alone would be overridden by the display above, and a card
   whose names all fit must show nothing at all here. */
.gm-more[hidden] { display: none; }

/* WHEN THE ROW CANNOT SHOW ONE NAME, IT STOPS TRYING.
 *
 * Measured on a 375px phone: a card carrying ×15 and a NEW badge leaves the
 * names about fifty pixels of a 246px row. Fifty pixels is the left-hand third
 * of one name dissolving into the card, with the word "for" in front of it
 * pointing at nothing — and on the replay card, where a "PLAYED 21:14" chip
 * takes 130px of the same row, it is thirty pixels and not even that. So the
 * names give the row up and the chip beside them says "15 people" instead.
 * Mark's own suggestion, applied exactly where the measurement says it is the
 * better read and nowhere else: a card whose one name fits still says "for
 * Jo", at every width.
 *
 * CLIPPED TO NOTHING, NOT `display: none`. Content clipped to zero width is
 * still in the accessibility tree, so a screen reader reads every name on a
 * card that visually shows none — the truncation this whole round is about is
 * a fact about paint, and it should not reach anybody who is not looking at
 * the screen. `display: none` would have taken the names out of the document
 * for them as well.
 *
 * AND THE MASK GOES WITH IT, because there is nothing left to soften: the mask
 * exists so a clipped pill dissolves instead of being sliced, and a row with
 * no width shows no pill to slice. The tip-marker count that also lives in
 * this row is clipped with everything else, which is what already happened to
 * it on these cards — it sat at the very end of the row, past every name, and
 * the fade reached it first. Getting that marker out from behind the fade is a
 * separate job from this one. */
.gm-under.is-counted .gm-for {
  flex: 0 0 auto;
  max-width: 0;
  -webkit-mask-image: none;
  mask-image: none;
}

/* ============ AND "No names given" IS WORDS, NOT PILLS ============
 *
 * With the act sharing this row the words were the thing that gave way: the row
 * squeezed .gm-for, the fade took the last 44px of it, and a card read
 * "No names give" with the n missing. A truncated sentence is not a smaller
 * sentence, it is a mistake on the screen.
 *
 * The fade exists so a clipped PILL dissolves rather than being sliced, and
 * there is no pill here, so neither the fade nor the shrinking has anything to
 * do. The words hold their width and the act, which is the flexible thing on
 * this row by design, takes what is left. :has() is already how this file asks
 * a card about its own contents; see .gm-card:has(.gm-under.is-doubled). */
.gm-under .gm-for:not(:has(.gm-name-pill)) {
  flex: 0 0 auto;
  -webkit-mask-image: none;
  mask-image: none;
}

/* THE ROW SAYS THE NUMBER ONCE.
 *
 * A collapsed row on a 375px phone read "×11  ● PLAYED 13:51  11 people":
 * eleven twice, 41px apart, on the narrowest row on this screen. Measured, the
 * three chips came to 273px in a 205px box — so .gm-under, which is
 * `overflow: hidden`, drew the last of them as "11 peo" with no right-hand
 * edge. The one element on the card whose job is to say who asked was the one
 * being sliced in half, and only on the cards that had already given up their
 * names and had nothing else left to say it with.
 *
 * The badge is the copy that goes, not the chip. "11 people" says what the
 * eleven counts and is the visible affordance for the peek; "×11" says the
 * digits and nothing else. Losing it costs 48px of duplication and no fact —
 * and only where writeNamesRow() has measured the two numbers to be equal.
 * A card where they differ keeps both, because there they are two facts.
 *
 * THE TITLE LINE IS NOT AFFECTED. This is the row-two copy of the badge; the
 * copy that lives beside the title is `display: none` at every width where
 * this class can be set, and above a 480px card the row has room for
 * everything and never collapses in the first place. */
.gm-under.is-doubled .gm-count.is-row2 { display: none; }
/* AND THE COPY BESIDE THE TITLE GOES THE SAME WAY, because the paragraph above
 * is wrong about where this can happen. It says the title line is not affected
 * since "above a 480px card the row has room for everything and never
 * collapses in the first place". Measured on the fifteen-name fixture at a
 * 768px viewport, it does collapse: Sweet Caroline drew "×11" beside the title
 * and "11 people" on the row beneath it, the same number twice on one card.
 * The class was set correctly — `doubled` in writeNamesRow() is computed from
 * the numbers, not the width — but the rule above only ever hid the row-two
 * badge, and above a 480px card the row-two badge is the one already turned
 * off. It hid the invisible copy and left the visible one.
 * :has() BECAUSE THE TWO ARE SIBLINGS' CHILDREN. is-doubled is set on
 * .gm-under and this badge is inside .gm-line next to it, so no descendant
 * selector reaches from one to the other.
 * (0,3,0) AGAINST (0,2,0), which is what makes it safe to put here rather than
 * after the container query that turns .is-inline back on. A container query
 * raises specificity by nothing — the trap written up against .gm-played
 * below — so an equal-specificity rule would have had to sit later in the file
 * to win. This one wins on specificity wherever it sits, and it belongs beside
 * the rule it completes. */
.gm-card:has(.gm-under.is-doubled) .gm-count.is-inline { display: none; }
/* A ROW WITH NO WIDTH SHOULD NOT ALSO COST A GAP. .gm-for is clipped to zero
   here and the flex gap on either side of it is still charged for — seven
   pixels of nothing, on the one row that is short of them. */
.gm-under.is-counted .gm-for { margin-inline-end: calc(var(--card-inline-gap) * -1); }
/* AND WHEN EVEN THE CHIP WILL NOT FIT, IT GOES AND THE BADGE COMES BACK.
   The last rung of the ladder in markHiddenNames(): measured, not guessed, and
   only ever on a row that has already given up its names and is still over its
   box. "×11  ● PLAYED 14:09" is 179px of a 205px row and every edge on it is
   whole; "● PLAYED 14:09  11 peo|" was 225px of the same row. Both text rows
   still open the panel on a tap, so nothing is unreachable — only unlabelled.
   The chip is aria-hidden either way, so this changes nothing for a reader. */
.gm-under.is-crowded .gm-more { display: none; }

/* SOMEBODY PUT MONEY IN, AND THE PILL IS THE MARK.
 *
 * Read on an iPad on a mic stand, at arm's length, in a room lit for the
 * audience rather than the musician. What has to be findable in one glance is
 * WHICH PERSON, not how much: the amount left this surface on 2 September 2026
 * because a running total printed beside a name was read as a payment for the
 * card it sat on, twice, by two different musicians.
 *
 * ============ WHY THE PILL IS FILLED AND NOT TINTED ============
 *
 * The obvious move is a second hue: make a tipper green, or gold, or anything
 * that is not the accent. It is the wrong move on this screen and the Stage
 * theme is the reason. Stage exists for function rooms and wedding venues
 * running sequenced LED lighting, and its own file states the rule it is built
 * on: hue becomes unreliable under a colour wash, so luminance, shape and
 * position carry all meaning and hue is only ever allowed to decorate. A
 * tipper marked by colour alone would be marked in exactly the venues most
 * likely to produce one. The card already says this about the "Yours" mark,
 * in those words: never a colour on its own.
 *
 * So the difference is FILL. Every other pill on the row is a 1px border over
 * a sunken plate; this one is a solid block of the accent with its ink on top.
 * That is a different shape and a large step in luminance, both of which
 * survive a wash and survive a dichromat simulation, and neither of which
 * needs the reader to resolve a hue at two metres. The hue still moves with
 * the theme, because --action carries it; it simply is not what is doing the
 * work.
 *
 * THE INK PAIRING IS DECLARED, NOT BORROWED. --action-text is the token every
 * theme already solves against a filled --action slab, which is what this is,
 * so scripts/check-contrast.mjs measures it in all three themes without a new
 * pair having to be invented for one pill. */
/* ON THE CARD FACE ONLY, AND THE SELECTOR SAYS SO.
   The panel lists the same people in the same pills and must NOT fill them: it
   is one row per person with an amount beside each, so a solid slab per row
   would be most of the dialog painted in the accent, and the ring that marks
   the person who was tapped would have nothing to sit against. There the tip
   is said in words on its own line, which is a panel's job. */
.gm-for .gm-name-pill.is-tipped {
  border-color: var(--action);
  background: var(--action);
  color: var(--action-text);
  font-weight: var(--weight-strong);
}
/* THE TAG GOES WITH IT. Its colour is set against a sunken plate a few rules
   down, and on a filled slab that pairing is not the one that was measured. */
.gm-for .gm-name-pill.is-tipped .gm-tag,
.gm-for .gm-name-pill.is-tipped .gm-name { color: var(--action-text); }

/* THE COIN, AND IT IS THE WHOLE OF THE MARK NOW.
 *
 * Sized off the name it sits beside rather than in pixels, and nudged on the
 * baseline so a row of them does not ride above the names. It carries no
 * figure: what a person has given is read in the panel one tap away, and the
 * aria-label says the amount so that a screen reader is not made to open a
 * dialog for something the pill would otherwise have told it.
 *
 * currentColor, so it inherits the ink of the filled pill rather than naming a
 * colour that would have to be re-solved for every theme. */
.gm-tipped {
  display: inline-block;
  margin-left: .5em;
  vertical-align: -.14em;
  color: currentColor;
  font-variant-numeric: tabular-nums;
}
.gm-tipped.gm-tipped-coin {
  width: .95em;
  height: .95em;
  vertical-align: -.08em;
}
/* .gm-tipped-pill AND .gm-tipped-more ARE GONE, rules and all. They styled the
   two grey count pills that carried an amount for a tipper with no pill of
   their own; every tipper has a pill of their own now, so there is nothing
   left for either to style. Deleted rather than left behind, on the same
   argument .gm-tip-glyph was: a rule with no element is a rule somebody will
   eventually write markup for. */

/* AND IN THE PANEL, WHERE THE FIGURE ACTUALLY LIVES.
 *
 * Its own line under the name rather than a span on the end of it, because
 * this is now the only place on the live screen that answers "what has this
 * person given" and it was previously the quieter of two. The label is
 * carried with it: a bare amount under a name is the exact ambiguity the card
 * was just cured of. */
/* A PILL IN THE PANEL CAN BE TWO ROWS TALL, which is what carries the amount.
   Without the wrap the flex-basis below does nothing and the line sits beside
   the name instead of under it, which is the arrangement the card was just
   cured of. */
.gm-peek-names .gm-name-pill { flex-wrap: wrap; }
.gm-peek-given {
  /* FULL WIDTH AND ALIGNED LEFT, both stated rather than relied on. The pill is
     a flex row with its items centred, and a wrapped second line inherits that
     centring, so the label floated in from the left edge and did not line up
     with the name above it. */
  flex-basis: 100%;
  width: 100%;
  text-align: left;
  margin-top: 1px;
  color: var(--text-secondary);
  font-size: .92em;
  font-weight: var(--weight-normal);
  line-height: 1.35;
}
.gm-peek-given .gm-tipped {
  margin-left: .3em;
  color: var(--action-on-surface);
  font-weight: var(--weight-strong);
}
/* THE ONE THE TAP WAS AIMED AT. A ring rather than a fill, because the fill is
   how a tipper is marked on the card and reusing it here would say the wrong
   thing about anybody pointed at who has not tipped. Outside the box, so it
   cannot move anything in a list that is already laid out. */
.gm-peek-names .gm-name-pill.is-pointed {
  outline: 2px solid var(--action-on-surface);
  outline-offset: 2px;
}

/* WHICH PERSON THIS IS, WHERE THE NAME DOES NOT SAY.
 *
 * A figure and a digit, and the figure is not decoration. This card already
 * carries two numbers — the ×N count and the £N amount — and a bare "2" among
 * them would be read as a third quantity rather than as an identity. The
 * silhouette says "a person" before the digit says which one.
 *
 * NOT AN ACCENT, and not a colour of its own. Colour on this screen means
 * action, per docs/THEMING.md, and a palette of person-colours would both break
 * that rule and run out at about six people while they were still supposed to
 * be telling each other apart. --text-secondary keeps the tag quieter than the
 * name it sits beside, which is the right order: the name is the answer where
 * there is one, and the tag is what stands in when there is not. */
/* THE FIGURE AND THE NUMBER ARE ONE THING, and the gap has to say so.
 *
 * At .18em they read as two: a person icon, and then a count that happened to
 * be next to it. They are not two facts — the icon says "a person" and the
 * digit says "which one", and together they are a single identifier. So the
 * space between them is closed to almost nothing, and the space that WAS there
 * moves outward, between the name and the identifier. Proximity is the only
 * thing on this pill saying what groups with what.
 *
 * .04em rather than 0: at 11px on a phone the digit's left sidebearing is
 * already doing most of the work, and zero lets a 1 touch the shoulder of the
 * figure. */
.gm-tag {
  display: inline-flex;
  align-items: center;
  gap: .04em;
  flex: none;
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}
/* Only when something precedes it inside the pill. A tag standing alone IS the
   pill's contents and must not be pushed off its own left padding. */
/* The gap that left the inside of the tag reappears here, wider than it was:
   this is the join that matters, between somebody's name and which person they
   are.
 *
 * THE NAME IS WRAPPED SO THIS SELECTOR CAN EXIST. It was
 * `.gm-tag:not(:first-child)`, on the assumption that a tag following a name is
 * not the first child — but :first-child counts ELEMENT children, and the name
 * was a bare text node, so the tag WAS the first element and every named pill
 * silently missed this margin. Measured at 0px. A sibling selector needs a
 * sibling, so the name has one now. A tag standing alone IS the pill's contents
 * and still must not be pushed off its own left padding, which falls out of
 * this for free. */
.gm-name + .gm-tag { margin-left: .5em; }
.gm-tag-glyph {
  display: block;
  flex: none;
  width: .8em;
  height: .8em;
}
.gm-tag-n {
  font-weight: var(--weight-strong);
  line-height: 1;
}

/* THE COIN, ON EVERY CARD THAT PERSON IS ON.
 *
 * There is no longer a card that carries an amount and a card that does not, so
 * there is no split for this rule to serve: the coin IS the mark, everywhere.
 * The size is set here because .tip-coin in css/tip.css fills its parent. */
.gm-name-pill.is-tipped .tip-coin { width: .95em; height: .95em; }

/* THE CHART NOTE, AND NOW ONLY THE CHART NOTE. The tip note that shared this
   class was deleted with the figure it explained, and its localStorage key with
   it. What is left sits under the two counts and above the queue, quiet enough
   to be skipped. */
.gm-tip-note {
  margin: 0;
  padding: calc(var(--gm-space) * .55) 0 0;
  color: var(--text-muted);
  font-size: var(--gm-t-micro);
}

/* ============================================================ THE TIP STRIP
 *
 * SIX SECONDS ABOVE THE QUEUE when a tip lands, then gone. In flow rather than
 * floating, on the same argument the break strip is: it must never cover a
 * card or a control, and above all it must never arrive over Played or Skip
 * between the decision to press one and the finger reaching it.
 *
 * ONE STRIP, AND THE HEIGHT IS THE REASON. Two of these stacked would push the
 * whole queue down while a thumb was already travelling towards a button, so
 * the module keeps exactly one on screen and queues the rest. See the arrival
 * strip block in js/views/gigmode.js.
 *
 * FOUND BY LUMINANCE AND POSITION, not by hue. Same rule as the tipped pill a
 * few hundred lines up and the same reason: on Stage the screen is being washed
 * with sequenced colour and hue cannot be trusted to carry anything. This is a
 * filled slab in the quiet accent surface with a solid accent edge down its
 * leading side, which is a shape and a step in brightness before it is a
 * colour.
 *
 * THE WHOLE THING IS THE BUTTON. It is dismissed by tapping it, mid-song, in
 * bad light, so the target is the strip rather than a close control tucked in a
 * corner of it. There is nothing destructive behind the tap: it clears an
 * announcement and shows the next one, and the running total and its breakdown
 * still hold every figure. */
.gm-tipstrip {
  padding: calc(var(--gm-space) * .55) 0 0;
}
.gm-tipstrip-body {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 1px;
  width: 100%;
  margin: 0;
  padding: calc(var(--gm-space) * .5) calc(var(--gm-space) * .7);
  border: 1px solid var(--action);
  border-left-width: 4px;
  border-radius: var(--radius-sm, 8px);
  background: var(--action-quiet);
  color: var(--action-quiet-text);
  font: inherit;
  text-align: left;
  cursor: pointer;
  /* IT ARRIVES, RATHER THAN BEING THERE ALREADY. A strip that simply exists on
     the next repaint is one a musician cannot tell from part of the furniture.
     Short, and reduced-motion switches it off rather than shortening it. */
  animation: gmTipStripIn .18s ease-out;
}
@keyframes gmTipStripIn {
  from { opacity: 0; transform: translateY(-3px); }
  to   { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .gm-tipstrip-body { animation: none; }
}
/* THE MONEY LEADS. It is the fact being announced, so it takes the weight and
   the size, and the song underneath is deliberately quieter: that line is there
   to help find a person, not to be read first. */
.gm-tipstrip-lead {
  display: inline-flex;
  align-items: center;
  font-size: var(--gm-t-body);
  font-weight: var(--weight-strong);
  font-variant-numeric: tabular-nums;
}
.gm-tipstrip-lead .gm-tag { color: inherit; }
.gm-tipstrip-song {
  color: var(--text-secondary);
  font-size: var(--gm-t-micro);
  font-weight: var(--weight-normal);
  /* One line. A long title truncates rather than folding the strip onto a
     second row, because the height of this thing is load-bearing. */
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* THE ACTIONS SIT AT THE RIGHT-HAND EDGE OF THE CARD, ALWAYS SIDE BY SIDE, and
   they never move to another row whatever else has to. They used to be a
   COLUMN of two — 94px tall, which set the height of every card in the queue. */
.gm-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  /* THE SAME GAP THE CARD USES BETWEEN ITS COLUMNS, so Chords→Played and
     Played→Skip are one distance and the three controls read as one evenly
     spaced row rather than a pair with something next to it. At 8px the pair
     was the tightest gap on the card, and these are the two controls a
     musician must not confuse while looking at a room instead of a screen. */
  gap: var(--card-col-gap);
}
/* THE TWO THAT MATTER, AND THE ONLY TWO ON THIS SCREEN WHERE A MIS-TAP COSTS A
   REAL THING: it marks the wrong song played in front of a room.
   "They are what I hit mid-song so they get whatever space is going, but on a
   laptop there is no reason for them to stay phone-sized." So this is the
   PHONE end of the scale and the container queries below take it up twice: on
   a 375px screen the title needs the width more than these do, and 44px tall
   is the dimension a thumb actually misses on. */
.gm-played, .gm-skip {
  flex: 0 0 auto;
  /* WIDER THAN "Played" NEEDS, so the width is this number and not whatever
     the label happens to measure. At 66 the minimum did not bind, the buttons
     took their content width, and that width moved by a pixel whenever
     anything else on the card changed by a fraction, which is a size nobody
     chose and a measurement that cannot be asserted. */
  min-width: 72px;
  max-width: 170px;
  /* ============ 36 DRAWN, 44 TAPPABLE ============
   *
   * These were 44px tall, which is the floor for something tapped one-handed
   * mid-song, and the whole card was 44 plus a 12px gap plus the Find chords
   * row: 100px, which fits THREE on an iPhone SE where four should fit.
   *
   * Mark's call, 3 September 2026: phones expect smaller controls, and the
   * music apps everybody already uses draw them smaller than this.
   *
   * BUT THE TAP AREA DOES NOT SHRINK WITH THE BUTTON, and that is the whole
   * trick. Those apps draw a small control and listen over a larger one; the
   * finger has not got smaller and a mis-tap here marks the wrong song played
   * in front of a room. So the box is 36px and the ::after below reaches 4px
   * past it top and bottom, into the gap, where there is nothing else to hit.
   * 36 + 8 is 44, which is the number that was there before.
   *
   * The 8px is spent VERTICALLY ONLY. Played and Skip sit side by side with
   * one gap between them, so a horizontal overhang would put each one's hit
   * area inside the other's, which is the exact mis-tap this is protecting. */
  position: relative;
  height: 36px;
  padding: 0 12px;
  border-radius: var(--radius);
  font-size: var(--gm-t-body);
  font-weight: var(--weight-medium);
  white-space: nowrap;
}
.gm-played::after,
.gm-skip::after {
  content: "";
  position: absolute;
  inset: -4px 0;
  /* Nothing is drawn. This is a hit area and only a hit area. */
}
/* 1.25, not the inherited 1.5. The card carries five things — title, artist,
   who asked, a chart link and two actions — and every one of them was sitting
   in a line box half again as tall as its own text, which is a rule for
   paragraphs. Measured on the card: .gm-song 26px for 17px of type, .gm-artist
   31px for 15px. */
.gm-song, .gm-artist, .gm-for { line-height: 1.25; }

/* ============ A TITLE THAT DOES NOT FIT SCROLLS ITSELF ============
 *
 * Hold, creep across, stop, snap back, wait, repeat. Mark's brief, 4 September
 * 2026: subtle, over a few seconds, not a ticker.
 *
 * INLINE UNTIL IT IS SCROLLING, and that is not a detail. text-overflow only
 * puts an ellipsis on text that is a direct inline child of the clipping box,
 * so making this an inline-block permanently would take the ellipsis off every
 * card in the queue to buy an animation to the handful that overflow. It stays
 * inline, weighs nothing, and becomes an inline-block only on the elements
 * js/views/gigmode.js has measured as too narrow.
 *
 * AND THE ELLIPSIS GOES WHEN IT DOES, because a title that is about to show you
 * its tail should not also be claiming it has one hidden.
 *
 * THE SNAP BACK IS A JUMP, not a slide: two keyframes a hundredth of a percent
 * apart. Sliding back reads as a second animation and doubles how long the card
 * is moving; the eye should get one journey and then stillness.
 *
 * PROPORTIONS: a fifth of the cycle holding at the start, half of it travelling,
 * a fifth holding at the end so the tail can actually be read, then the jump and
 * a short wait. The duration itself comes from the distance, so everything
 * creeps at one speed. */
@keyframes gmScrollText {
  0%, 18%      { transform: translateX(0); }
  68%, 86%     { transform: translateX(var(--gm-scroll-by, 0)); }
  86.01%, 100% { transform: translateX(0); }
}
.gm-scroll-in { display: inline; }
.gm-song.is-scrolling,
.gm-artist.is-scrolling { text-overflow: clip; }
.gm-song.is-scrolling > .gm-scroll-in,
.gm-artist.is-scrolling > .gm-scroll-in {
  display: inline-block;
  will-change: transform;
  animation: gmScrollText var(--gm-scroll-ms, 6s) ease-in-out infinite;
}
/* NOT FOR ANYBODY WHO HAS ASKED FOR STILLNESS. The ellipsis comes back with it,
   so the card still says there is more to read; it simply does not move. */
@media (prefers-reduced-motion: reduce) {
  .gm-song.is-scrolling > .gm-scroll-in,
  .gm-artist.is-scrolling > .gm-scroll-in { animation: none; display: inline; }
  .gm-song.is-scrolling,
  .gm-artist.is-scrolling { text-overflow: ellipsis; }
}

/* Filled against outlined: the pair is separable with the colour removed. */
.gm-played { background: var(--positive); color: var(--positive-ink); }
.gm-skip { background: var(--surface-raised); color: var(--text-primary); border: 1px solid var(--border-strong); }


/* ---- chords, in the actions row ----------------------------------------
 *
 * ONE CONTROL, AND IT MOVED OUT OF THE CARD BODY.
 *
 * It used to be a link plus an "Add link"/"Change link" button, sitting after
 * the names inside the card body, with a paste box that opened underneath. All
 * of that is gone: Mark's words were "Mid-gig is not when I set links", and the
 * second control plus its box was costing a row on every card in the queue for
 * a job nobody does while playing. Setting a link is the Songs tab's ⋯ sheet.
 *
 * What is left opens a tab: the chart if the song has one, an Ultimate Guitar
 * search if it does not. It sits in .gm-actions with Played and Skip, small and
 * outlined against their fill, so the two controls that matter stay the biggest
 * things on the card — and so it costs the text nothing.
 *
 * DELIBERATELY BELOW THE 44px TARGET FLOOR, which is a decision and not an
 * oversight. The floor exists for controls hit mid-song without looking, and on
 * this card those are Played and Skip, which keep theirs. Opening a chord sheet
 * is done while looking at the screen. The same trade is argued a few hundred
 * lines down for .sw-update-notice .swu-dismiss. */
/* SMALL AND NEVER FILLED, AND IT NEVER GROWS WITH THE OTHER TWO. "It is a
   secondary action sitting next to two primary ones and should not compete
   with them."
   NO FILL IS THE PART THAT DOES THE WORK. Played is a solid green plane and
   Skip is a raised one; being transparent is what puts this button in a
   different class from both, and it stays transparent in BOTH of its states —
   the colour below separates the two chart states from each other, never from
   Played and Skip.
   THIS IS THE PHONE SIZE and it is smaller than the 32px it takes everywhere
   else, because at 375px "Find chords" — the longest this label ever gets —
   was 100px wide against Played's 68px and Skip's 68px directly above it. The
   secondary action was the biggest control on the card at the width where room
   is scarcest, and that is the one thing it was told not to be. At 30px tall
   with 11px type it is about 80px and 2,400 square pixels against Played's
   2,990: shorter, narrower and unfilled, on every card. */
/* THE BASE IS THE SEARCH STATE, which is the quiet one — see .gm-chart-go
   .has-link below for the pair and the argument. A neutral border and
   secondary ink: the same treatment the card gives every other piece of
   supporting text, on a control that goes off to a results page rather than to
   anything the musician has chosen. --border-strong rather than --border so
   the boundary clears 3:1 against the card in all three themes, which is what
   WCAG 1.4.11 asks of a control's edge. */
.gm-chart-go {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* Only ever between the label and the arrow, and only the search state has
     one — a single flex item has no gap to apply it to. */
  gap: .3em;
  /* 28, NOT 30, AND IT MOVED WITH THE PAIR BESIDE IT. This has to read as the
     secondary control, which it does by being clearly shorter than Played and
     Skip rather than slightly shorter. Those went from 44 to 36, so 30 left a
     6px difference that read as a third equal button; 28 keeps a clear step
     down. The 480px container query below takes it to 32 against their 48. */
  height: 28px;
  padding: 0 8px;
  border-radius: var(--radius);
  border: 1px solid var(--border-strong);
  background: transparent;
  color: var(--text-secondary);
  font-size: var(--gm-t-micro);
  font-weight: var(--weight-medium);
  line-height: 1;
  white-space: nowrap;
  text-decoration: none;
}
.gm-chart-go:hover { background: var(--action-quiet); }
/* THE ARROW THAT SAYS THIS ONE LEAVES. Sized off the label rather than in
   pixels, so it holds its proportion through both button sizes and through
   Stage's larger --font-scale; flex:none so a squeezed row never shaves it
   down to a tick. See GO_ARROW in js/views/gigmode.js for why only the search
   state carries it. */
.gm-go-arrow {
  flex: none;
  width: 1.05em;
  height: 1.05em;
  /* The stroke is currentColor, so it darkens and lightens with the label
     rather than needing a colour of its own in every theme. */
}

/* ---- how the card changes as it gets wider ------------------------------
 *
 * Measured against gmcard — the CARD, not the window. The same card is drawn
 * in the queue and in a modal, and a window width says nothing about how much
 * room it has in either. Every threshold below is a card width in pixels, and
 * the card is the window less about 36px on a phone and capped at 900px above
 * a 940px window, so: a 375px phone is a 339px card, a 430px phone 394, a
 * 768px iPad 732, and anything from a 1024px window up is 900.
 *
 * 480px — MORE ROOM THAN A PHONE. Played and Skip take their second size and
 * the artist stops being the first thing to close up, because from here the
 * line is wide enough that only a genuinely long title overruns it, and when
 * one does it is the title that should lose the characters.
 *
 * 640px — A TABLET ON A MIC STAND, OR A LAPTOP. Chords moves up beside Played
 * and Skip, the controls span both rows, and the card becomes three plain
 * columns: text, Chords, the two buttons. 640 rather than anything lower
 * because Chords costs the title 112px the moment it joins that row, and below
 * this the title cannot afford it: at a 394px card the wide layout leaves the
 * song 102px, which is "Sweet Caroline" rendered as "S.". Played and Skip take
 * their full size here too — "on a laptop there is no reason for them to stay
 * phone-sized".
 *
 * CHORDS DOES NOT GROW AT ANY OF THEM. "It is a secondary action sitting next
 * to two primary ones and should not compete with them." */
@container gmcard (min-width: 480px) {
  /* ROOM FOR BOTH, so they go back to being two things: the wrapper stops
     being a box, the song and the act become flex items of the title line, and
     the truncation lands on the title alone. */
  .gm-title { display: contents; }
  /* AND THE COUNT COMES BACK TO THE TITLE'S ROW. Above a 480px card the line
     is wide enough that only a genuinely long title overruns it, so the 48px
     the badge takes is no longer the difference between a readable song name
     and an unreadable one — and beside the title is where it belongs, because
     it is a fact about the song rather than about the request. `revert` rather
     than a chosen display value: a span's is `inline`, which blockifies to
     exactly what it was as a flex item of .gm-line, so nothing about this
     badge's box is decided in two places. */
  .gm-count.is-inline { display: revert; }
  .gm-count.is-row2 { display: none; }
  /* AND THE ACT COMES BACK TO THE TITLE'S ROW, on the same rule as the count
     and for the same reason: above a 480px card the line is wide enough to
     carry both, and beside the song is where it belongs, because the two of
     them are one fact about which song this is. */
  .gm-artist.is-inline { display: revert; }
  .gm-artist.is-row2 { display: none; }
  /* A flex item again, so the row's own gap separates it and the padding it
     needed as inline text would double up. */
  .gm-artist { flex: 0 0 auto; max-width: 45%; padding-left: 0; }
  .gm-played, .gm-skip { min-width: 84px; height: 48px; padding: 0 16px; }
  /* Room for the badge as well as the chip and the names — see the rule at
     .gm-card.is-replay .gm-new for why a phone drops it. */
  .gm-card.is-replay .gm-new { display: revert; }
  /* Back to the size it is everywhere else. The phone shrinks it because there
     it is the WIDEST control on the card otherwise — see .gm-chart-go. */
  .gm-chart-go { height: 32px; padding: 0 12px; font-size: var(--gm-t-small); }
}
@container gmcard (min-width: 640px) {
  /* Chords leaves the bottom row for the top one, so the spacer column it was
     paying for is not needed and the actions go back to a column of their own. */
  .gm-actions { grid-column: 3; }
  /* ON THE SAME CENTRELINE AS THE OTHER TWO, which is what `grid-row: 1 / 3`
     buys and why it is not just `grid-row: 1`. Sitting in the top row it was
     centred on the TOP ROW while Played and Skip, spanning both, were centred
     on the CARD — so Chords floated about 14px above their centreline on every
     card in the queue. Nothing measured it and it was the first thing a fresh
     pair of eyes called out: "it reads as an element that has slipped, not as
     a row." Spanning the same rows puts all three on one line across.
     Aligned on its RIGHT edge, which is the edge that sets the gap to Played,
     so that gap is identical on every card. The left edges step in and out
     because "Chords" is 62px and "Find chords" 100px, and every card is its
     own grid — there is no column shared down the list for a `stretch` to
     equalise, only a column per card sized to the one button in it. */
  .gm-chart-go { grid-column: 2; grid-row: 1 / 3; justify-self: end; }
  /* AND THE NAMES GIVE THE CHORDS COLUMN BACK, which is not optional now that
     Chords occupies both rows of it: with the names still spanning columns one
     and two, the pill row ran straight underneath the button and "Tom" and
     "+2" were painted behind it on the ten-name card. It reads better as well
     as being correct — the names row now stops on the same vertical as the
     controls block starts, so the card is a text column and a control column
     rather than two overlapping shapes. It costs the pill row about 112px,
     which is a name or two on the rare card that has ten of them; the count
     itself is on the title line as ×10 and is not lost. */
  .gm-under { grid-column: 1; }
  .gm-actions { grid-row: 1 / 3; }
  /* FULL SIZE, and it starts at the tablet rather than the laptop — a phone is
     the exception on this screen, not the rule, and a 768px iPad on a mic stand
     is where the gig actually happens. 56px is what they were before any of
     this began, which is what Mark asked for: "Played and Skip go back to being
     larger, as they were before... on a laptop there is no reason for them to
     stay phone-sized."
     IT COSTS THE CARD NOTHING. Two text rows plus the gap and the padding come
     to 76px, so any button up to 58px tall is absorbed by height the card
     already has. 52px was left on the table for no reason.
     152px MINIMUM, AND THAT NUMBER IS THE SKIP LABEL. Skip reads "Already
     played" on a song already played tonight, which is wider than "Skip", and
     with the pair right-aligned a wider Skip slides Played leftwards: the
     green target then moved on exactly the cards that already need a second
     look. The minimum is set wider than the longest label so every button in
     the queue is one width and Played does not move.

     IT SAID 128, AND 128 WAS RIGHT FOR A LABEL THAT NO LONGER EXISTS. The
     comment here read "wider than 'Not again' needs", and skipLabel() was
     later changed from "Not again" to "Already played" because the copy rules
     forbid promising a song will not be played. That is a good change and
     nothing updated this number with it, so the label overflowed the minimum
     by 24px and Played moved again on the replay card, which is the exact
     fault the minimum exists to prevent. Measured at 152 rather than guessed
     at, and tests/live-card-density.mjs had been reporting it on five of its
     seven devices for as long as the label has been the new one. */
  .gm-played, .gm-skip {
    min-width: 152px;
    height: 56px;
    padding: 0 20px;
    font-size: var(--gm-t-mid);
  }
}
/* More room again, so they take more of it: a 900px card is a laptop or an
   iPad in landscape, read and hit from further away than anything else here. */
@container gmcard (min-width: 820px) {
  /* 168, NOT 144. Same reason as the 152 above and the same arithmetic: the
     padding is 24 a side here rather than 20, so "Already played" needs eight
     more than it does at the smaller size. 144 was below the label at this
     width too. */
  .gm-played, .gm-skip { min-width: 168px; padding: 0 24px; }
}

/* ============ THE SAME THREE STEPS FOR AN ENGINE WITHOUT @container =========
 *
 * @container needs Safari 16 and Chrome 105. Below that all three blocks above
 * are inert and every card in the queue stays in its phone layout, whatever
 * the screen. On a 768px iPad on a mic stand that means Played and Skip at
 * 84x48 instead of 144x56: the two controls hit mid-song, one-handed, in a
 * room lit for the audience, at a third less area than they were designed
 * with. That is the fault worth carrying a second copy of these rules for.
 *
 * THE BREAKPOINTS ARE MEASURED, NOT GUESSED. The card is the queue's width and
 * the queue is the shell minus its 18px of padding on each side, so the card
 * runs exactly 36px behind the viewport. Walked in 4px steps in a real
 * browser: the card first reaches 480 at a 516px viewport, 640 at 676 and 820
 * at 856. Those three numbers are these three media queries.
 *
 * ============ AND @supports IS THE MECHANISM HERE, DELIBERATELY ============
 *
 * A plain fallback declaration is the right answer for a single property, and
 * it is what Job A uses in css/public.css: the cascade drops the value it
 * cannot parse and keeps the one before it. There is no equivalent for a whole
 * RULE BLOCK. The cascade cannot express "only when @container is unsupported"
 * on its own, and source order cannot either: it would leave these applying on
 * a modern engine wherever the viewport and the card disagree about which side
 * of a breakpoint they are on.
 *
 * They agree today, because the card's width is a fixed 36px behind the
 * viewport. `@supports not` means they do not have to keep agreeing. The
 * container queries win where supported by construction rather than by an
 * argument that has to stay true.
 *
 * THE TWO FALLBACKS ALREADY IN css/dashboard.css ARE PLAIN MEDIA QUERIES AND
 * THAT IS CORRECT THERE, which is worth writing down so nobody "tidies" them
 * into this shape. Those two mirror MAX-width container queries, and a
 * container can never be wider than the viewport it is inside: a max-width
 * media query therefore cannot fire while the container query would not have.
 * The guarantee is free in that direction and unavailable in this one. */
@supports not (container-type: inline-size) {
  @media (min-width: 516px) {
    .gm-title { display: contents; }
    .gm-count.is-inline { display: revert; }
    .gm-count.is-row2 { display: none; }
    .gm-artist { flex: 0 0 auto; max-width: 45%; padding-left: 0; }
    .gm-played, .gm-skip { min-width: 84px; height: 48px; padding: 0 16px; }
    .gm-card.is-replay .gm-new { display: revert; }
    .gm-chart-go { height: 32px; padding: 0 12px; font-size: var(--gm-t-small); }
  }
  @media (min-width: 676px) {
    .gm-actions { grid-column: 3; grid-row: 1 / 3; }
    .gm-chart-go { grid-column: 2; grid-row: 1 / 3; justify-self: end; }
    .gm-under { grid-column: 1; }
    .gm-played, .gm-skip {
      min-width: 152px;
      height: 56px;
      padding: 0 20px;
      font-size: var(--gm-t-mid);
    }
  }
  @media (min-width: 856px) {
    /* 168, NOT 144. Same reason as the 152 above and the same arithmetic: the
     padding is 24 a side here rather than 20, so "Already played" needs eight
     more than it does at the smaller size. 144 was below the label at this
     width too. */
  .gm-played, .gm-skip { min-width: 168px; padding: 0 24px; }
  }
}

/* ^ THESE LIVE HERE, AFTER EVERY RULE THEY OVERRIDE, and that is load-bearing.
   A container query raises specificity by NOTHING: `.gm-played` inside
   @container and `.gm-played` outside it are both (0,1,0), so the later one in
   the file wins outright. With this block up beside .gm-for — where it was
   first written — the base 44px kept winning and the buttons never grew at any
   width. That is the second time the same trap has cost a measurement on this
   card. */

/* THE ONE WITH A LINK BEHIND IT IS THE ONE WITH THE COLOUR.
 *
 * It was the other way round and Mark called it: "'Find chords' has a purple
 * outline and purple text, while 'Chords' has a purple outline and grey text.
 * That reads backwards to me, since Chords is the one with a link behind it."
 *
 * It read backwards, and it also pointed the accent at the wrong fact. Almost
 * nothing has a chart saved at the start of a night, so the old pair painted
 * an accent button on every card in the queue and drew the single card that
 * could be opened in one tap in grey — the accent was marking the ABSENCE of a
 * chart. Now it appears exactly where there is something to open, so "which of
 * these can I just pull up" is answered by a glance down the queue rather than
 * by reading eight labels.
 *
 * THREE THINGS SEPARATE THE PAIR, AND ONLY ONE OF THEM IS COLOUR: the ink and
 * border go from neutral to accent, the weight goes up a step, and the search
 * state carries an arrow this one does not. On a dim stage, or for anyone who
 * cannot rely on hue, the arrow is the difference that still reads — which is
 * the same argument .gm-tipped makes a few hundred lines up.
 *
 * NEITHER OF THEM GAINS A FILL. That is what keeps both off Played and Skip's
 * ground: those two are filled and 44-56px tall, these two are transparent and
 * 30-32px, in both states.
 *
 * NOT A WORD ABOUT WHERE THE LINK POINTS. .has-link is set by whether the song
 * has a chart_url at all; a Drive PDF, a OnSong page and an Ultimate Guitar tab
 * are one state and get one button. */
.gm-chart-go.has-link {
  border-color: var(--action);
  color: var(--action-on-surface);
  font-weight: var(--weight-strong);
}
/* Its own hover, because the base one is --action-quiet and this button is
   already tinted towards the accent — the shared rule was a hover you could
   not see. --action-hover at a low alpha is not available as a token, so this
   uses the same raised plane the rest of the card hovers to. */
.gm-chart-go.has-link:hover { background: var(--surface-raised); }

/* The phone-only block that used to live here is gone with the controls it
   sized. It shrank a two-chip row to 28px so it would fit in the slack beside a
   94px stacked actions column — a careful fix to a problem the row no longer
   has, now that there is one chip and it sits in the actions themselves. */

/* ================= THE PEEK: READING WHAT DID NOT FIT =================
 *
 * The card is one row high on purpose, and two things on it pay for that: a
 * long title truncates and a long list of names fades. Both are now tappable
 * and both open the same panel. See the block above cardInner() in
 * js/views/gigmode.js for why one panel rather than two, and why a centred one
 * rather than a bubble pinned to what was tapped.
 *
 * THE TARGETS ARE THE TWO TEXT ROWS, WHOLE. This is hit one-handed, mid-song,
 * looking at a room — so the target is the entire left half of the card rather
 * than the eleven characters of title that happen to be on screen. Neither row
 * contains a control at any width: Played, Skip and the chart button are their
 * own grid items in the columns to the right of both. */
.gm-line[data-peek],
.gm-under[data-peek] {
  cursor: pointer;
  /* The tap lands on a box, not on the glyphs inside it, and a stray drag
     across a card mid-scroll should not leave half a song title selected. */
  -webkit-user-select: none;
  user-select: none;
  /* NOT ONE PIXEL OF PADDING OR MARGIN, and that is load-bearing rather than
     minimal. These two are grid items in a card whose every dimension is
     measured in tests/live-card-density.mjs, and column one is the box the
     title's truncation is calculated from — padding here would take width
     straight off the song name, which is the thing the rest of this round is
     buying back. The press wash fills the row's own box instead, and the
     rounding is the card's radius stepped in so it reads as part of the card
     rather than as a rectangle drawn on top of it. */
  border-radius: calc(var(--radius) - 6px);
}
/* THE ONLY THING THAT SAYS THESE ARE TAPPABLE BEFORE THEY ARE TAPPED IS THE
   PRESS ITSELF, and that is a deliberate trade rather than an omission. A
   permanent affordance on both rows of every card — a chevron, a rule, a
   tinted plate — is thirty of them on a queue whose entire complaint was that
   it was too busy to read at a metre. The "+13" chip carries the discovery for
   the names, the ellipsis carries it for the title, and the wash confirms the
   tap landed for everything else. No hover branch: this screen is a touch
   screen, and a wash that follows a mouse across a queue is noise. */
.gm-line[data-peek]:active,
.gm-under[data-peek]:active { background: var(--surface-raised); }
/* Keyboard only. A ring drawn on every tap would be a ring left behind on the
   card after the panel closed and focus came back to it. */
.gm-line[data-peek]:focus { outline: none; }
.gm-line[data-peek]:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 1px;
}

/* The panel itself rides on .gm-modal-overlay and .gm-modal — the same scrim,
   the same safe-area padding, the same capped and scrollable card every other
   overlay in Gig Mode uses. Only what is different is written here. */
.gm-peek {
  /* Left, not centred. It is a title and a list, and a centred list of pills
     of different widths is a shape nobody can scan. */
  text-align: left;
  /* Narrower than the 400px .gm-modal default. This is read at a glance, not
     filled in, and a shorter measure is a faster read. */
  width: min(340px, 100%);
}
/* The full title, at the size the card would give it if the card had the room.
   It wraps — that is the whole point of the panel — and there is no line clamp
   on it: a song called something absurd is exactly the case this exists for. */
.gm-peek-song {
  margin: 0;
  color: var(--text-primary);
  font-size: var(--gm-t-song);
  font-weight: var(--weight-strong);
  line-height: 1.25;
  overflow-wrap: anywhere;
}
.gm-peek-artist {
  margin: 4px 0 0;
  color: var(--text-secondary);
  font-size: var(--gm-t-mid);
  line-height: 1.25;
  overflow-wrap: anywhere;
}
/* The count in words. The card says "×15" because it has room for two
   characters; here there is room to say what fifteen refers to. */
.gm-peek-who {
  margin: 16px 0 8px;
  color: var(--text-secondary);
  font-size: var(--gm-t-small);
  font-weight: var(--weight-medium);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
}
/* THIS ONE WRAPS, AND THAT IS THE DIFFERENCE BETWEEN IT AND THE CARD. The row
   on the card cannot wrap — a queue whose cards change height with how many
   people happened to ask is the complaint this whole screen was rebuilt for —
   and this panel is where that constraint is paid back. Same pills, same size,
   as many rows as it takes. */
.gm-peek-names {
  display: flex;
  flex-wrap: wrap;
  /* The card's own pill gap, written out rather than referenced: this panel is
     a sibling of the queue, not a descendant of a card, so --card-pill-gap
     does not reach it. */
  gap: 5px;
  /* Fifty people on one song is a real Saturday night. The card scrolls
     rather than growing off the top of a landscape phone. */
  max-height: 40dvh;
  overflow-y: auto;
  overscroll-behavior: contain;
}
.gm-peek-none {
  margin: 0;
  color: var(--text-secondary);
  font-size: var(--gm-t-body);
}
/* ============ AND WHAT THE MUSICIAN PUT THERE THEMSELVES ============
 * Only ever drawn on a card the ROOM has also asked for, where the pills above
 * it are the people who asked and this is the separate fact that the musician
 * queued it too. Ruled off from them rather than sitting among them: it is not
 * a person, it must never be counted as one, and a line that looked like one
 * more pill would be read as one more person. */
.gm-peek-mine {
  display: flex;
  align-items: baseline;
  gap: 6px;
  margin: 12px 0 0;
  padding-top: 12px;
  border-top: 1px solid var(--border);
  color: var(--action-on-surface, var(--action));
  font-size: var(--gm-t-body);
}
/* The note travels with the mark in the card's pill and it travels with this
   line here, so the rule that draws it inside a pill has to be undone. */
.gm-peek-mine .gm-mine-for {
  padding-left: 6px;
  border-left: 1px solid var(--border);
  color: var(--text-secondary);
}
/* A full-width target, because the scrim already takes any tap that misses it
   — this is for the person who wants a button rather than a gesture, and for
   anything driving the screen by keyboard or switch. */
.gm-peek-close {
  width: 100%;
  height: var(--gm-control);
  margin-top: 20px;
  border-radius: var(--radius);
  background: transparent;
  border: 1px solid var(--border-strong);
  color: var(--text-primary);
  font-size: var(--gm-t-mid);
  font-weight: var(--weight-medium);
}

.gm-empty {
  text-align: center;
  color: var(--text-secondary);
  padding: calc(var(--gm-space) * 4) 20px;
  font-size: calc(17px * var(--font-scale));
  line-height: var(--line-height);
}

.gm-footer { padding-top: calc(var(--gm-space) * 1.4); }
.gm-end {
  width: 100%;
  height: var(--gm-control);
  border-radius: var(--radius);
  background: transparent;
  border: 1px solid var(--border-strong);
  color: var(--text-secondary);
  font-size: var(--gm-t-mid);
  font-weight: var(--weight-medium);
}
/* Armed also changes its own label to "Tap again to end", so the state is not
   carried by the fill. --negative-text rather than --negative: the fill is the
   tinted plane, and the fill colour is not a text colour. */
.gm-end.is-armed {
  background: var(--negative-surface);
  color: var(--negative-text);
  border-color: var(--negative);
  font-weight: var(--weight-strong);
}

/* ---- the confirm window, made visible ----
 *
 * A DEPLETING BAR ACROSS THE FOOT OF THE BUTTON. The window is four seconds and
 * nothing used to say so, so a musician who tapped once and looked up at the
 * room had no way of knowing whether the second tap would end the gig or start
 * again.
 *
 * A BAR RATHER THAN A RING. This control is full width on a phone and 22rem on
 * a stand; a ring would have to sit inside it and would read as a spinner —
 * "wait" — rather than as "this is running out". A bar shrinking along an edge
 * is unambiguously a thing being used up, and a changing SHAPE is what carries
 * at arm's length in bad light where a changing NUMBER does not.
 *
 * The number is there too, in the label, and that redundancy is the point: it
 * is what a person holding the phone reads and what still works for anybody who
 * cannot perceive the movement. */
.gm-end { position: relative; overflow: hidden; }
.gm-end-label { position: relative; z-index: 1; }
.gm-end-bar {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 3px;
  background: var(--negative);
  transform: scaleX(0);
  transform-origin: left center;
}
/* Only ever visible while armed, so a stray class on a resting button cannot
   leave a red line under "End gig". */
.gm-end:not(.is-armed) .gm-end-bar { transform: scaleX(0); animation: none; }
.gm-end.is-armed .gm-end-bar.is-running {
  animation: gm-end-deplete 4s linear forwards;
}
@keyframes gm-end-deplete {
  from { transform: scaleX(1); }
  to   { transform: scaleX(0); }
}

/* REDUCED MOTION KEEPS THE COUNTDOWN AND LOSES THE GLIDE.
 *
 * Removing the bar here would leave the exact fault this fixes in place for the
 * people who asked for less movement. `steps(4)` drops it in four discrete
 * jumps, one a second — the information without the continuous motion — and the
 * seconds in the label carry the rest. */
@media (prefers-reduced-motion: reduce) {
  .gm-end.is-armed .gm-end-bar.is-running {
    /* `end`, not `start`. With steps(4, start) the bar jumps to the NEXT value
       at the top of each second, so it sat empty for the whole final second
       while the label still said "1s" — the two channels contradicting each
       other, which is worse than having one. With `end` the bar holds a
       quarter through that second and reaches nothing when the window does. */
    animation: gm-end-deplete 4s steps(4, end) forwards;
  }
}

/* The safe-area insets are added to the padding for the same reason the venue
   sheet adds them: this can open on a notched phone held sideways, where the
   left and right insets are real and the bottom one is not zero. On a desktop
   every env(safe-area-inset-*) is 0 and they cost nothing. */
/* Not portalled to <body> like the other sheets: this one is rebuilt with
   root().innerHTML on every live repaint, and moving it would mean
   restructuring the screen the app can least afford to break. It does not need
   to be. The live screen is height:100dvh with its own inner scroller, so the
   section carrying container-type is exactly one viewport tall and inset:0
   against it IS the viewport. Safe centring and a scrollable scrim cover the
   rest; see the note on .vm-overlay in css/dashboard.css. */
.gm-modal-overlay {
  position: fixed; inset: 0; z-index: 60;
  display: grid;
  place-items: center;
  place-items: safe center;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding:
    calc(20px + var(--safe-top, 0px))
    calc(20px + var(--safe-right, 0px))
    calc(20px + var(--safe-bottom, 0px))
    calc(20px + var(--safe-left, 0px));
  background: var(--surface-overlay);
}
.gm-modal-overlay[hidden] { display: none; }
/* IT HAD NO MAX-HEIGHT AT ALL. Its content is short today, so it happened to
   fit; anything added to it would have grown the card straight off the top and
   bottom of a landscape phone, where a centred grid item cannot be scrolled
   back into view. Capped and scrollable now, on the same rule as every other
   card in the app. */
.gm-modal {
  width: min(400px, 100%);
  max-height: 100%;
  overflow-y: auto;
  overscroll-behavior: contain;
  background: var(--surface);
    /* THE CARD'S OWN EDGE, against its own scrim. Measured 1.17:1 on Dark and
     1.13 on Stage, where a modal is a slightly different dark rectangle on a
     dark rectangle and the Stage one had almost no visible edge at all. The
     scrim darkens what is behind it, so the card is separated from the page
     but not from the wash sitting on top of it; the border is what draws the
     card, and it has to be strong enough to be seen. */
  border: 1px solid var(--border-strong);
  border-radius: var(--radius-lg);
  padding: 24px;
  text-align: center;
  box-shadow: var(--shadow-2);
}
.gm-modal h2 { font-size: calc(22px * var(--font-scale)); margin: 0 0 6px; }
.gm-extend-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin: 20px 0 12px; }
.gm-extend {
  height: calc(62px * var(--density));
  border-radius: var(--radius);
  background: var(--action);
  color: var(--action-text);
  font-size: calc(18px * var(--font-scale));
  font-weight: var(--weight-strong);
  font-variant-numeric: tabular-nums;
}
.gm-dismiss {
  width: 100%; height: calc(50px * var(--density));
  border-radius: var(--radius);
  background: transparent;
  border: 1px solid var(--border);
  color: var(--text-secondary);
  font-size: var(--gm-t-mid);
  font-weight: var(--weight-medium);
}

/* The queue is never more than one column, at any width: it is ordered by how
   many people asked, and that order stops being readable the moment the eye
   has to zigzag. Width beyond 900px goes to margins, and everything already
   dealt with lives in the bottom sheet at every size. */
@media (min-width: 900px) {
  /* Both rows of the bar fit on one line, so only the far right needs to clear
     the ✕. */
  .gm-bar-status { flex: 0 1 auto; padding-right: 0; }
  .gm-bar-clock { flex: 1 1 auto; padding-right: var(--gm-exit-clear); }
  .gm-counts { justify-content: center; }
}

/* ---- narrow: shed the bar's least useful pieces rather than wrap ----
 *
 * Drop order, least useful first. The clock and the countdown NEVER drop: the
 * clock is now the piece the bar is built around, and the countdown is the
 * number the screen exists to show.
 *
 * THE BREAKPOINTS MOVED. They were set against --font-scale 1 and a bar with
 * no tracking on its labels; Stage runs 1.12 with --tracking-wide at .14em,
 * which needs roughly 420px for the full clock row against 290 on a 390px
 * phone. A media query cannot see the theme, so the width at which a piece is
 * shed has to be the width the LARGEST theme needs it shed — otherwise Stage
 * silently pushes the countdown under the ✕, which is the one number the
 * screen exists to show.
 *
 * The cost is that Dark and Light lose the start time on a 390–560px phone
 * where it would still have fitted. That is the right way round: the start
 * time is history by the time anyone reads the bar, and it is the piece this
 * file already nominated as first to go. */

/* 1. The compact bar. Start time out, the word "left" out, gaps and label
 *    tracking tightened. Everything that remains is either moving or still
 *    informing a decision. */
@media (max-width: 560px) {
  .gm-remaining-word { display: none; }
  .gm-bar-clock { gap: 10px; }
  /* Wide tracking is right for a label with room around it and wrong for one
     fighting for a row. The uppercase and the size still separate it. */
  .gm-tlabel { letter-spacing: var(--tracking-normal); }
  .gm-tval { font-size: var(--gm-t-body); }
  .gm-clock { font-size: var(--gm-t-body); padding: 2px 9px 2px 7px; }
  .gm-clock-face { width: 14px; height: 14px; }
}

/* 1b. THE START TIME SURVIVES DOWN TO 430, WHICH IS WHERE IT STOPS FITTING.
 *
 * It used to go at 560, which meant the three-time group Mark asked for did not
 * exist on any phone: at 375 he got the chip and End, at 320 the chip alone.
 * 470 IS MEASURED, AND THE FIRST ANSWER WAS WRONG. Forcing Start on and
 * comparing the bar's scrollWidth to its client width said 430 "fitted" — but
 * fitting is flexbox shrinking things until they stop overflowing, and what it
 * had shrunk was the gap to the exit: the countdown overlapped the ✕ by 14px on
 * Dark and 21px on Stage. A row that reports no overflow while its largest
 * control sits on top of the button that leaves the screen is not fitting.
 *
 * 480 is the width with real room: 18px clear in both themes. So Start appears
 * from 480 up, and below that the row is the chip, End and the countdown, with
 * End going too under 360. Three times on a phone is not achievable at a
 * legible size alongside a 1.25x countdown and an exit, and this says so
 * rather than leaving a rule implying otherwise. */
@media (max-width: 470px) {
  .gm-t-start { display: none; }
}

/* 2. The end time, on the narrowest handsets only. The countdown beside it is
 *    the same fact in the form that is actually being asked. */
@media (max-width: 360px) {
  .gm-t-end { display: none; }
  /* THE VARIABLE, NOT THE PADDING, and that is the whole of a real fault.
   *
   * 25 September 2026. This set padding-left and padding-right directly and
   * left --gm-gutter saying 18px. The Go Live bar cancels the gutter with a
   * negative margin to reach both edges of the screen, so at 320px it
   * cancelled 18px of a 14px padding and came out 328px wide in a 320px
   * window: the page moved sideways, which is a standing rule against.
   *
   * The comment where --gm-gutter is declared says "Two places, one number".
   * It was one number until this line made it two. Setting the variable keeps
   * the promise: everything that reads it, the padding and the bar alike,
   * moves together. */
  .gm { --gm-gutter: 14px; }
}

/* 3. HOW MANY REQUESTS FIT ON THE SCREEN.
 *
 * Measured on a 375x667 handset with ten in the queue: TWO whole cards, each
 * 179px tall. A musician who has to scroll to see what is coming next is a
 * musician looking at a phone instead of a room, which is the one thing this
 * screen exists to prevent.
 *
 * Almost all of the 179px was the actions. .gm-card-main has a 200px flex
 * basis and the two buttons want 92px each, which on a 343px content width
 * does not fit — so they wrapped to their own full-width row, 56px tall, with
 * a 12px gap above it. Deliberate, and right for a tablet on a stand; wrong
 * for the smallest phone anybody is holding, where it spends a third of the
 * screen on two buttons and pushes the queue off the bottom.
 *
 * On a phone they go beside the text in a fixed column instead, stacked so the
 * title keeps roughly 230px to be read at half a metre in. 40px each rather
 * than 56: still a comfortable thumb target, and there are two of them with
 * nothing else nearby to mis-hit.
 *
 * The rest is rhythm — padding, gaps and one step off the title. Nothing here
 * touches --font-scale or --density, so Stage still arrives with its bigger
 * multipliers and simply shows fewer cards, which is the trade Stage is for. */
@media (max-width: 430px) {
  .gm {
    --gm-card-pad: calc(11px * var(--density));
    --gm-space:    calc(9px * var(--density));
    /* ============ AND THE WHOLE RAMP COMES DOWN WITH IT ============
     *
     * Mark, 3 September 2026: "Look at how lists are formed in Spotify. We
     * should be able to reduce font sizing to something similar. Including the
     * song title, which seems to be getting truncated fairly often."
     *
     * The truncation is the argument. This screen was sized from one sound
     * premise, that it is read at half a metre off a stand, and that premise
     * was applied to every string on it. A 17px title on a 375px card has about
     * 145px to live in and truncates on any song with four words: "Careless
     * Whispe...". Sixteen pixels of a whole title is more legible at half a
     * metre than seventeen of most of one.
     *
     * Only --gm-t-song was stepped down here before. The rest of the ramp
     * follows now, because the same argument applies to every line on the card
     * and the ladder exists so that this is one edit rather than twenty.
     *
     * NOT THE RHYTHM AND NOT THE TARGETS. --gm-space and --gm-card-pad are
     * above and unchanged, --gm-control is untouched, and Played and Skip keep
     * their 44px reach. Type only.
     *
     * THE THEME MULTIPLIER STILL APPLIES, so Stage arrives bigger than Dark
     * here as it does everywhere, which is what that token is for. */
    --gm-t-micro:  calc(10px * var(--font-scale));
    --gm-t-small:  calc(12px * var(--font-scale));
    --gm-t-body:   calc(13px * var(--font-scale));
    --gm-t-mid:    calc(14px * var(--font-scale));
    --gm-t-large:  calc(17px * var(--font-scale));
    --gm-t-song:   calc(16px * var(--font-scale));
  }
  /* THE STACKED ACTIONS COLUMN IS GONE, AND SO IS THE REASONING FOR IT.
     It said: side by side was measured worse, because the pair is 129px wide
     and taking that off the text made the names wrap onto another line. That
     was true of a card whose text needed 240px for a title on one line and an
     artist on another. The text is one truncating line now, so the pair costs
     the title some characters rather than costing the card a row — and the
     actions are 44px instead of 94px, which is what was setting the height of
     every card in the queue.
     Nothing narrow-specific is left here: the base rules put the actions in a
     row at every width, and the names drop to their own line below 30rem of
     card by the container query rather than by a viewport media query. */
  .gm-card { align-items: center; }
  /* AND THE TITLE STOPS AT TWO LINES.
     Moving the buttons beside the text took 129px off the title's width, and
     measured, that made the card TALLER than the stacked version it replaced:
     median 110px became 157px, because long titles went from two lines to
     four. Fixing the shape without fixing the wrap would have been a straight
     loss.
     Two lines of a 17px title is around forty characters, which holds all but
     a handful of real song names; past that it ellipses. A DECISION WORTH
     KNOWING ABOUT: the alternative was letting one card be 208px tall in a
     queue you are reading between songs, and the full title is still on the
     card's own screen when you tap it. The title keeps its weight and its
     size — it is still the largest thing on the card, which is the one rule
     that could not bend. */
  .gm-song { -webkit-line-clamp: 2; }
  /* The queue is the job; the counts row is reference. It keeps every word
     and loses the padding around it — the numbers are already the largest
     thing on that line and do not need air to be found. */
  .gm-counts {
    padding: calc(var(--gm-space) * .5) 0 calc(var(--gm-space) * .7);
    gap: 4px calc(var(--gm-space) * 1.4);
  }
  .gm-card.is-replay { padding-left: calc(var(--gm-card-pad) - 5px); }
}

/* Held back / already dealt with — collapsed by default so the queue keeps the
   screen. Both read the same rows as the queue above them. */
.gm-panel {
  margin-top: var(--gm-space);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  overflow: hidden;
}
.gm-panel > summary {
  cursor: pointer;
  list-style: none;
  padding: var(--gm-space) 18px;
  font-size: var(--gm-t-mid);
  font-weight: var(--weight-medium);
  color: var(--text-secondary);
  display: flex;
  align-items: center;
  gap: 10px;
}
.gm-panel > summary::-webkit-details-marker { display: none; }
.gm-panel > summary::after {
  content: '▾';
  margin-left: auto;
  transition: transform .15s ease;
}
.gm-panel[open] > summary::after { transform: rotate(180deg); }
.gm-panel-count {
  background: var(--border);
  color: var(--text-primary);
  border-radius: 999px;
  padding: 1px 9px;
  font-size: var(--gm-t-small);
  font-variant-numeric: tabular-nums;
}
.gm-panel-note {
  margin: 0;
  padding: 0 18px 10px;
  color: var(--text-secondary);
  font-size: var(--gm-t-body);
  line-height: var(--line-height);
}
.gm-past {
  display: flex;
  align-items: center;
  gap: var(--gm-space);
  padding: var(--gm-space) 18px;
  border-top: 1px solid var(--border);
}
.gm-past-main { flex: 1 1 200px; min-width: 0; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.gm-past-song { font-size: calc(17px * var(--font-scale)); color: var(--text-primary); min-width: 0; }
.gm-past-by {
  display: block;
  font-size: var(--gm-t-small);
  line-height: 1.1;
  color: var(--text-secondary);
}

/* ---- the Played / Skipped sheet -----------------------------------------
 *
 * Reference material, not a working surface: it slides up over the queue when
 * asked and stays out of the way otherwise. Rows are deliberately shorter and
 * tighter than queue cards — the only tap here is the occasional Undo. */
.gm-sheet {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  z-index: 40;
  /* A DIFFERENT plane. --surface is what queue cards and the held-back panel
     are already made of, so a sheet in --surface was a card the width of the
     screen: correct on the ladder, invisible in the room. --surface-raised is
     a step the palette guarantees in every theme — check-contrast.mjs enforces
     it — and in Light it is the step that separates MOST (#FFF against a warm
     grey page), which is why the panel is not accent-tinted all over: mixing
     purple into white walks the panel back down towards the page colour it is
     trying to sit above. The accent goes on the handle instead. */
  background: var(--surface-raised);
  /* The strong line, not the shadow, is what says "this is over the page": a
     bottom sheet's drop shadow falls off the bottom of the screen, so the
     separation has to come from the ladder and the edge. Three pixels and the
     accent mixed into it, because this edge is read at arm's length from a
     mic stand, not from a desk. */
  border-top: 3px solid color-mix(in srgb, var(--action) 55%, var(--border-strong));
  /* Curved top corners: the edge that reads as a panel sliding over the page
     rather than a strip welded to the bottom of it. */
  border-radius: var(--radius-lg) var(--radius-lg) 0 0;
  /* Cast UPWARDS, over the queue. --shadow-2 points down, off the bottom of
     the screen, which is why the panel had no lift at all. */
  box-shadow: 0 -8px 26px rgba(0, 0, 0, .24);
  /* The sheet is fixed, so .gm's bottom padding never reached it and the
     handle sat directly on the home indicator. Padding the CONTAINER rather
     than the handle means the inset survives opening and closing: the panel's
     own colour fills the safe area, and nothing inside it ever moves. */
  padding-bottom: env(safe-area-inset-bottom, 0px);
}
/* Height is set here, not left to the sum of its padding: the handle is the
 * only part of the sheet that exists when the sheet is shut, so it is the
 * whole target, and a 51px strip pinned to the bottom edge of a tablet is
 * both hard to find and hard to hit. See the breakpoints below. */
.gm-sheet-handle {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
  width: 100%;
  min-height: var(--gm-sheet-handle);
  padding: 12px 16px;
  /* The band that carries the colour. When the sheet is shut this IS the
     sheet, so the accent goes where it is actually seen; when it is open the
     same band reads as the panel's header. The theme supplies the hue —
     violet in Dark, purple in Light, amber in Stage — so all three get a
     visibly coloured panel without any of them being named here. */
  background: color-mix(in srgb, var(--action) 12%, var(--surface-raised));
  color: var(--text-secondary);
  font-size: var(--gm-t-mid);
  font-weight: var(--weight-medium);
  border-radius: var(--radius-lg) var(--radius-lg) 0 0;
  cursor: grab;
  /* The handle is dragged vertically, so the browser must not claim the
     gesture for scrolling first. */
  touch-action: none;
  user-select: none;
}
.gm-sheet.is-dragging .gm-sheet-handle { cursor: grabbing; }
/* The grab bar. Says "this pulls up" before any word is read.
 * Open is a SIZE change as well as a colour change — the two states were
 * previously identical geometry in two hues, which is nothing at all under a
 * coloured wash. The caret beside the counts flips too. */
.gm-grip {
  width: 48px;
  height: 5px;
  border-radius: 999px;
  background: var(--border-strong);
  transition: width .22s ease, height .22s ease, background-color .22s ease;
}
.gm-sheet.is-open .gm-grip { width: 72px; height: 7px; background: var(--action); }
.gm-handle-row { display: flex; align-items: center; gap: 10px; }
.gm-handle-part b { color: var(--text-primary); font-variant-numeric: tabular-nums; }
/* --text-secondary, not --text-muted: the handle is a tinted band now, and
   muted has no contrast headroom left on it in Dark. A separator does not need
   to be the quietest thing on the screen to read as a separator. */
.gm-handle-sep { color: var(--text-secondary); }
.gm-handle-caret { font-size: 12px; transition: transform .28s cubic-bezier(.4, 0, .2, 1); }

/* Open and close by HEIGHT, not by the `hidden` attribute: a hidden element
   cannot animate, which is why the panel used to snap in and out. The body
   holds a fixed-height inner so the list inside can be its own scroll region
   rather than growing the panel. */
.gm-sheet-body {
  height: 0;
  overflow: hidden;
  transition: height .3s cubic-bezier(.32, .72, 0, 1);
}
.gm-sheet.is-open .gm-sheet-body { height: 50vh; height: 50dvh; }
/* While a finger is on it the height is set frame by frame, so the transition
   has to be out of the way or it fights the drag. */
.gm-sheet.is-dragging .gm-sheet-body { transition: none; }
.gm-sheet-inner {
  display: flex;
  flex-direction: column;
  height: 50vh;
  height: 50dvh;
  border-top: 1px solid var(--border);
  /* The safe-area inset moved up to .gm-sheet, which holds it whether the
     panel is open or shut. */
  padding: 12px 16px 14px;
  max-width: 900px;
  margin: 0 auto;
  width: 100%;
}
/* The one scrolling thing in the panel. Contained, so a drag that reaches
   either end stops there instead of carrying on into the page (Safari 16 and
   later; before that the page is held still by syncPanelLock in gigmode.js).
   pan-y, so a finger on a row is a scroll and never a page gesture. */
.gm-sheet-list,
.gm-before-scroll {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  touch-action: pan-y;
}

.gm-tabs { display: flex; gap: 8px; padding-bottom: 10px; flex: none; }
.gm-tab {
  flex: 1;
  min-height: calc(40px * var(--density));
  border-radius: var(--radius);
  background: transparent;
  border: 1px solid var(--border);
  color: var(--text-secondary);
  font-size: var(--gm-t-body);
  font-weight: var(--weight-medium);
}
/* Selected is a filled plane, a heavier label and an underline rule. All three
   read with the colour gone; the underline's hue is the last cue, not the
   first. It used to be a border colour and nothing else, with Played and
   Skipped told apart by red against violet. */
.gm-tab.is-on {
  /* Was a flat --surface-raised, which the tinted panel underneath has since
     caught up with. Mixed further along the same accent so the filled plane
     stays a plane rather than dissolving into its own background. */
  background: color-mix(in srgb, var(--action) 26%, var(--surface-raised));
  border-color: var(--border-strong);
  color: var(--text-primary);
  font-weight: var(--weight-strong);
  box-shadow: inset 0 -3px 0 0 var(--action);
}
/* The selected tab is marked by the SAME indicator whichever tab it is. It
   used to underline Played in --negative so that "the colour means one thing
   across the screen" — but --negative means something went wrong, and putting
   it under the tab that lists the songs the artist successfully played inverts
   that. It was also the only red on the screen, so it took the eye first. The
   category is already carried by the tab's own word. */
.gm-tab-count { font-variant-numeric: tabular-nums; }
.gm-pager { flex: none; }

/* ---- Popular here ----------------------------------------------------------
 *
 * The third tab: what the last few crowds in THIS room asked for. Reference
 * material — no Undo, no actions, nothing here belongs to anyone currently in
 * the building — so the rows are lighter than the Played/Skipped rows beside
 * them and carry no hit target at all.
 *
 * The learn block leads and is the only thing on this screen drawn with the
 * action colour down its edge. That is deliberate: it is the one list in the
 * app that says what to do BETWEEN gigs rather than during one, and it is
 * being read in the one place it applies to. */
.gm-here { padding-bottom: 4px; }
.gm-here-lead {
  margin: 2px 0 8px;
  font-size: var(--gm-t-small);
  font-weight: var(--weight-strong);
  color: var(--text-primary);
  line-height: 1.3;
}
.gm-here-lead.is-second { margin-top: 18px; color: var(--text-secondary); }
.gm-here-block.is-learn {
  border-left: 3px solid var(--action);
  padding-left: 10px;
}
.gm-here-row {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: calc(32px * var(--density));
  padding: 3px 0;
  border-bottom: 1px solid var(--border);
  font-size: var(--gm-t-body);
  line-height: 1.3;
}
.gm-here-row:last-child { border-bottom: 0; }
/* Two lines now, not one: the title, and who recorded it under it. The
   ellipsis moves onto each line separately — a single nowrap box around both
   would put the artist on the same line as the title and truncate the pair. */
.gm-here-song { flex: 1; min-width: 0; }
.gm-here-title,
.gm-here-by {
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Quiet on purpose: it settles "which Landslide?", it does not compete with
   the title for the glance. */
.gm-here-by {
  font-size: var(--gm-t-small);
  line-height: 1.1;
  color: var(--text-secondary);
}
.gm-here-n {
  flex: none;
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}
.gm-here-unit { font-size: var(--gm-t-small); margin-left: .35em; }
/* Three parts on the handle is a lot for a narrow phone, but "Popular here" is
   the ONLY sign that the tab exists on a night where nothing has been played
   yet — which is exactly the night it is most worth opening. So it is never
   hidden; it truncates, and the caret keeps its place. */
.gm-handle-row { min-width: 0; overflow: hidden; }
.gm-handle-part { flex: none; white-space: nowrap; }
.gm-handle-part.is-here {
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  color: var(--text-secondary);
}
.gm-handle-caret { flex: none; }
.gm-sheet-row {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: calc(34px * var(--density));
  padding: 3px 0;
  border-bottom: 1px solid var(--border);
  font-size: var(--gm-t-body);
  line-height: 1.3;
}
.gm-sheet-row:last-child { border-bottom: 0; }
.gm-sheet-song { flex: 1; min-width: 0; }
.gm-sheet-title,
.gm-sheet-by {
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.gm-sheet-by {
  font-size: var(--gm-t-small);
  line-height: 1.1;
  color: var(--text-secondary);
}
.gm-sheet-x { color: var(--text-secondary); font-size: var(--gm-t-small); font-variant-numeric: tabular-nums; }
/* Skipped because it had already been played, not because it was unknown. The
   same chip as .gm-prior.is-played, filled marker and all, because it is the
   same fact seen from the other panel. */
.gm-again {
  display: inline-flex;
  align-items: center;
  gap: .45em;
  flex: none;
  padding: 1px 8px;
  border-radius: 999px;
  border: 1px solid var(--negative);
  background: var(--negative-surface);
  color: var(--negative-text);
  font-size: var(--gm-t-micro);
  font-weight: var(--weight-medium);
  white-space: nowrap;
}
.gm-again::before {
  content: '';
  flex: none;
  width: .58em; height: .58em;
  border-radius: 50%;
  background: currentColor;
}
.gm-sheet-at {
  flex: none;
  color: var(--text-secondary);
  font-size: var(--gm-t-small);
  font-variant-numeric: tabular-nums;
}
.gm-sheet-row .gm-undo { min-height: calc(30px * var(--density)); padding: 0 12px; font-size: var(--gm-t-small); }
.gm-sheet-empty { padding: 18px 0; text-align: center; color: var(--text-secondary); font-size: var(--gm-t-body); }

/* ---- how tall the shut handle is -----------------------------------------
 *
 * The base is the phone: 78px, half again what this was. The handle is hit
 * with a thumb while the other hand is holding a neck, and it is the closest
 * control on the screen to the bottom edge — the one place a grip has to be
 * generous rather than tidy.
 *
 * Tablet doubles it. A tablet is on a stand at arm's length, so the target
 * that was merely small on a phone is a sliver there, and there is no shortage
 * of height to pay for it. Gated on min-height as well as min-width, because
 * min-width alone also catches a phone turned sideways — 844×390 — where a
 * 104px handle would eat a quarter of the screen the queue lives on. */
@media (min-width: 700px) and (min-height: 700px) {
  :root { --gm-sheet-handle: calc(104px * var(--density)); }
  .gm-sheet-handle { gap: 12px; font-size: var(--gm-t-large); }
  .gm-grip { width: 62px; height: 6px; }
  .gm-sheet.is-open .gm-grip { width: 92px; height: 8px; }
}

/* And the other way for a phone on its side, where every pixel the handle
   takes comes straight off the queue. Still bigger than the 51px it was. */
@media (orientation: landscape) and (max-height: 480px) {
  :root { --gm-sheet-handle: calc(64px * var(--density)); }
  .gm-sheet-handle { gap: 6px; font-size: var(--gm-t-body); }
}
.gm-pager {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 14px;
  padding-top: 10px;
  color: var(--text-secondary);
  font-size: var(--gm-t-small);
  font-variant-numeric: tabular-nums;
}
.gm-pager button {
  min-width: 38px;
  min-height: 32px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-primary);
  font-size: var(--gm-t-mid);
}
.gm-pager button:disabled { opacity: .35; }
.gm-undo {
  flex: none;
  min-height: calc(40px * var(--density));
  padding: 0 18px;
  border-radius: var(--radius);
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-primary);
  font-size: var(--gm-t-mid);
  font-weight: var(--weight-medium);
  cursor: pointer;
}
.gm-undo:active { background: var(--border); }
.gm-panel-filtered .gm-past { flex-wrap: wrap; }
.gm-panel-filtered .gm-actions { display: flex; gap: 8px; }

/* Everything that moves, in one place. The two status pulses were not covered
   before, which is the case that matters most: a blinking dot is exactly what
   a vestibular trigger looks like, and both states have a word beside them. */
@media (prefers-reduced-motion: reduce) {
  .gm-sheet-body,
  .gm-handle-caret,
  .gm-grip,
  .gm-panel > summary::after { transition: none; }
  .shell-status.is-live .dot,
  .nav-golive.is-live .golive-circle,
  .gm-live-dot { animation: none; }
  .shell-drawer { animation: none; }
  /* The ring is the live cue when the blink is off. */
  .nav-golive.is-live .golive-circle { box-shadow: 0 0 0 3px var(--action); }
}

/* ==========================================================================
   Request signals — badge, toast, edge pulse
   ==========================================================================

   Exactly one of these fires per request, decided by js/request-signal.js:
   the edge pulse on the live screen, the toast plus the badge anywhere else,
   and neither when the app is in the background (that one is a push).

   NO RAW COLOUR HERE EITHER. The pulse is --action, the badge is --negative
   over --negative-ink. Both read correctly in Dark, Light and Stage because
   neither names a hue. */

/* -------------------------------------------------- unseen-request badge --- */

/* Anchored to the Go Live circle rather than to the nav cell, because the
   circle is what the artist looks at and it is the thing that moves between
   the bottom bar, the rail and the sidebar. Absolutely positioned inside it,
   so it costs the layout nothing: the bar does not grow, the four other tabs
   do not shift, and the button's hit area is the button, unchanged. */
.golive-circle { position: relative; }

.golive-badge {
  position: absolute;
  /* Up and out along the top-right diagonal. Centred on the circle's edge, so
     it clears the "Go Live" / "LIVE" label completely. */
  top: 4px;
  right: 4px;
  transform: translate(50%, -50%);
  min-width: 21px;
  height: 21px;
  padding: 0 5px;
  border-radius: 11px;
  /* The alert token, not the accent: the circle underneath is already
     --action, and an accent badge on an accent button is not a badge. */
  background: var(--negative);
  color: var(--negative-ink);
  /* Knocked out of the bar the same way the circle is, so the badge reads as
     a separate object rather than a bite out of the button. */
  border: 2px solid var(--surface);
  display: grid;
  place-items: center;
  font-size: 11px;
  font-weight: var(--weight-strong);
  line-height: 1;
  letter-spacing: 0;
  /* AND IT DOES NOT INHERIT THE LABEL'S SETTING. text-indent is an inherited
     property, so the half-a-tracking-unit the circle puts back to centre its
     own label was landing inside the badge as well: it widened a 21px disc to
     22 off air and 23 live, which is a badge that changes size when the state
     changes. letter-spacing was already reset here for the same reason. */
  text-indent: 0;
  text-transform: none;
  font-variant-numeric: tabular-nums;
  /* The button is the target; the badge is never one. */
  pointer-events: none;
}
.golive-badge[hidden] { display: none; }

/* ------------------------------------------------------- request toast --- */

/* Not the .toast class the views use: those sit inline under the control they
   are about and would move the page. This one is fixed, so a request landing
   while the artist is typing an email address in Settings cannot push the
   field out from under their cursor. */
.request-toast {
  position: fixed;
  left: 50%;
  top: calc(env(safe-area-inset-top, 0px) + 14px);
  transform: translateX(-50%);
  z-index: 95;
  max-width: min(420px, calc(100vw - 32px));
  padding: 11px 16px;
  border-radius: var(--radius-lg);
  border: 1px solid var(--border-strong);
  background: var(--surface-raised);
  color: var(--text-primary);
  box-shadow: var(--shadow-2);
  font-size: 15px;
  font-weight: var(--weight-medium);
  text-align: left;
  pointer-events: none;
}
.request-toast[hidden] { display: none; }
.request-toast .rt-label {
  display: block;
  font-size: 11px;
  font-weight: var(--weight-strong);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--action-on-surface);
  margin-bottom: 2px;
}
.request-toast .rt-song {
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ----------------------------------------------------------- edge pulse --- */

/* A frame, not a wash: the middle of the screen is where the artist is
   reading, so the alert lives entirely in the periphery. Inset from the
   physical edge by the safe area plus a few pixels so a rounded corner or the
   home indicator cannot clip it. pointer-events: none and no participation in
   layout — it cannot cover a control in any way that matters and cannot shift
   one by a pixel. */
.edge-pulse {
  position: fixed;
  top: calc(env(safe-area-inset-top, 0px) + 6px);
  right: calc(env(safe-area-inset-right, 0px) + 6px);
  bottom: calc(env(safe-area-inset-bottom, 0px) + 6px);
  left: calc(env(safe-area-inset-left, 0px) + 6px);
  /* Above the About modal (100). It is an alert, and it obscures nothing. */
  z-index: 120;
  display: none;
  pointer-events: none;
  border: 8px solid var(--action);
  border-radius: 22px;
  opacity: 0;
  /* The bleed is what makes it register from the corner of an eye at half a
     metre. Both directions: inward over the screen, outward into the bezel. */
  box-shadow: inset 0 0 22px 3px var(--action), 0 0 16px 1px var(--action);

  /* AND THE WHOLE SCREEN CHANGES WITH IT.
   *
   * A frame in the periphery is a small target. What the peripheral field is
   * best at is a change in luminance across a LARGE area, so the frame brings
   * the rest of the screen with it: the page shifts as the flash rises and
   * settles back as it falls.
   *
   * A FLAT WASH, BECAUSE backdrop-filter DID NOT RENDER ON A REAL PHONE.
   *
   * This was backdrop-filter: brightness() first, which is the better-looking
   * tool for the job — a true multiply, so every plane keeps its ratio to every
   * other one and nothing can clip. It measured correctly in Chromium and shows
   * nothing at all in Safari, which is the browser this app actually runs in:
   * an iOS home-screen PWA is WebKit. The frame kept flashing and the screen
   * never moved.
   *
   * The cause is stacking, not support. Per Filter Effects, an element with
   * opacity below 1 forms a Backdrop Root, and this element's opacity is
   * animated from 0 for the whole sequence — so a backdrop-filter on it has, by
   * the spec's own model, nothing behind it to filter. Chromium is lenient and
   * filters the page anyway; WebKit is not. The elegant part of the old design,
   * riding one opacity animation, was exactly what broke it.
   *
   * So: a plain background colour, scaled by that same opacity. Alpha
   * compositing is the oldest, most uniformly implemented thing in the
   * rendering stack — result = page x (1 - a) + wash x a — and there is no
   * stacking context, backdrop root or vendor prefix anywhere near it. The
   * frame itself has always been an opacity animation on this element and has
   * always worked; the wash is now the same mechanism, so if the frame renders
   * the wash renders.
   *
   * WHAT THAT COSTS, HONESTLY. A wash is not a multiply. It lays ink over the
   * page, so it moves the background and the text towards each other and
   * contrast falls a little — where brightness() left text easier to read
   * mid-flash, this leaves it slightly harder. The per-theme values are chosen
   * to keep that fall small (nothing drops below 11:1 at the peak). A black
   * wash is the exception and is exactly a multiply, which is why Light uses
   * one — see --pulse-wash in js/themes/app/light.js.
   *
   * The colour and the amount are per theme, because the direction is not even
   * the same on all three: see the notes on --pulse-wash in js/themes/app/*.js.
   * The transparent fallback means a page with no token flashes its frame and
   * washes nothing, rather than dropping the declaration.
   *
   * Clipped to this element's border box, so the outermost few pixels of the
   * screen do not change. They are underneath a frame at full intensity at the
   * time, which is the one part of the screen nobody could be missing. */
  background: var(--pulse-wash, transparent);
}

/* iPad and up: thicker, because the same 8px is a smaller fraction of a
   bigger screen and reads as a hairline from a mic stand. */
@media (min-width: 700px) {
  .edge-pulse { border-width: 12px; border-radius: 26px; }
}

/* FIVE FLASHES IN FIVE SECONDS. One 1000ms cycle, run five times:
     0 → 100ms   rise to full          (10%)
     100 → 250ms hold at full          (25%)
     250 → 700ms fall to nothing       (70%)
     700 → 1000ms gap before the next
   linear, deliberately: an eased ramp is a slower onset, and onset is the
   only part of this that the peripheral field actually detects.

   WHY FIVE AND NOT THREE. Three was the original count and it was too few:
   three seconds is roughly the length of a chorus line, and an artist who is
   mid-phrase, looking at their hands, or turned towards the room simply misses
   the whole thing. There is no second chance — nothing repeats it and the only
   remaining trace is a queue that has quietly grown by one. The cycle itself is
   unchanged, so nothing about the onset is different; there are just two more
   chances to catch it. Five seconds is still comfortably shorter than the gap
   between two requests at a busy gig.

   The count lives here and the teardown timer lives in js/edge-pulse.js, and
   the two must agree: 5 × 1000ms. tests/request-signals.mjs samples the real
   opacity frame by frame and asserts on five peaks, so they cannot drift. */
.edge-pulse.is-pulsing {
  display: block;
  animation: edgePulseFlash 1000ms linear 5;
}
@keyframes edgePulseFlash {
  0%   { opacity: 0; }
  10%  { opacity: 1; }
  25%  { opacity: 1; }
  70%  { opacity: 0; }
  100% { opacity: 0; }
}

/* Reduced motion: one static flash of the same colour at the same intensity,
   held and then removed. js/edge-pulse.js picks this branch, and the media
   query below is the belt to that braces — if the two ever disagree, nothing
   animates.

   THE WASH COMES WITH IT, and deliberately: it is inherited from .edge-pulse
   above, so this branch is the screen held one step brighter — or one step
   darker, on Light — for 800ms, rather than moved up and down five times. A
   steady change in brightness is not motion, and it is the part of the signal
   that survives best once the flashing part has been taken away, which leaves
   this branch with more to say than the frame alone, not less. Nothing here
   fades, ramps or repeats. */
.edge-pulse.is-static {
  display: block;
  opacity: 1;
}

@media (prefers-reduced-motion: reduce) {
  .edge-pulse.is-pulsing {
    animation: none;
    opacity: 1;
  }
}

/* ------------------------------------------------------ boot failure --- */

/* Shown only when the artist record could not be FETCHED — not when it is
   absent, which is a different thing and goes to onboarding instead.
 *
 * It exists because the alternative is a redirect, and a redirect from here is
 * what produced the loop: the dashboard sent the visitor to the signup page,
 * the signup page found their account and sent them back, and neither screen
 * stayed still long enough to be read. Something that stays on screen and says
 * what happened is worth more than any amount of automatic recovery. */
.boot-failure {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 14px;
  max-width: 34rem;
  margin: 0 auto;
  padding: 24px;
  background: var(--bg);
  color: var(--text-primary);
  text-align: left;
}
.boot-failure h1 {
  margin: 0;
  font-size: 22px;
  font-weight: var(--weight-strong);
  letter-spacing: var(--tracking-normal);
}
.boot-failure p {
  margin: 0;
  font-size: 15px;
  line-height: var(--line-height);
  color: var(--text-secondary);
}
/* The server's own words. Quiet, selectable, and small — it is for a bug
   report, not for the musician. */
.boot-failure-detail {
  font-size: 13px;
  color: var(--text-muted);
  overflow-wrap: anywhere;
  user-select: text;
}
.boot-failure-detail:empty { display: none; }
.boot-failure-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 4px; }
.boot-failure-actions button {
  min-height: 48px;
  padding: 0 20px;
  border-radius: var(--radius);
  font-size: 15px;
  font-weight: var(--weight-medium);
}

/* AN UPDATE IS READY AND IS NOT BEING APPLIED YET.
 *
 * Only ever on screen when a gig is live: with no gig running the app simply
 * reloads and there is nothing to say. So this is read mid-set, which decides
 * everything about it — it sits at the BOTTOM, above the nav rather than over
 * the header, it is quiet rather than accented, and it is dismissible with one
 * thumb. js/sw-register.js hides it outright on the live screen; the queue is
 * the only thing on that screen and nothing may sit on top of it.
 *
 * Not .request-toast: that one announces a request, is temporary and is
 * pointer-events: none. This is persistent, has a control in it, and is the
 * least urgent thing the app will ever say. */
/* ===========================================================================
   THE WIDTH BUG, DIAGNOSED RATHER THAN GUESSED AT. Fixed 25 August 2026.

   Reported from a real iPhone in dark mode: the message wrapped to one or two
   words per line in a tall narrow column beside the button, and the whole thing
   rendered as a small square floating mid-screen.

   MEASURED IN A BROWSER AT 390px BEFORE ANYTHING WAS CHANGED:

     toast          213 x 248   — 55% of the screen, and roughly square
     message column  44px wide  — 13 lines, about SIX characters per line
     computed left  195px       — exactly half the viewport
     computed max-width 366px   — never reached

   THE CAUSE IS THE POSITIONING, NOT THE FLEX CHILD. `position: fixed` with
   `left: 50%`, no `right` and no `width` makes the box shrink-to-fit against
   the space from its left edge to the edge of the containing block — which is
   half the viewport, 195px. `transform: translateX(-50%)` then slides it back
   to centre, so it LOOKS centred while its layout width was halved before any
   of the content was laid out. `max-width: min(440px, 100vw - 24px)` computed
   to 366px and never got a chance to matter, because available was already 195.

   That is also why it was only ever seen on a phone: at 1280px, half the
   viewport is 640px, the max-width caps it at 440, and the bug is invisible.

   The obvious suspect — .swu-text being a flex child that could be crushed by
   its siblings — was a symptom. It has `flex: 1 1 auto` and would have laid out
   perfectly well in a box that was the width it was supposed to be.

   THE FIX is to give it two edges and let it centre with auto margins, so the
   available width is the real one and max-width does the limiting.
   =========================================================================== */

/* IT IS A GRID, AND IT STACKS AT EVERY WIDTH.
 *
 * Message on its own row, actions on the next. No breakpoint, deliberately:
 * the message is 66 characters, which is two lines even at the full 420px, so
 * putting the button beside it buys nothing on a wide screen and costs the
 * prose a third of its width on a narrow one. One layout that is right
 * everywhere beats two that each have to be checked.
 *
 * min-width: 0 on the message because a grid item's automatic minimum is its
 * min-content width, which for a long word would push the row wider than the
 * track. Belt and braces rather than load-bearing, now the box is sized right.
 *
 * NOT OVER THE NAV OR OVER THE PRIMARY ACTION. `bottom` clears the nav bar and
 * its safe area, and js/sw-register.js hides the notice outright on the live
 * screen, where the queue is the only thing that matters. */
.sw-update-notice {
  position: fixed;
  left: 12px;
  right: 12px;
  /* CLEAR OF THE NAV, WITH AIR. It was 76px, and the nav measures 77 at the
     default font scale on a device with no home indicator — so the notice
     kissed the top of the bar by a pixel, which is not "covering" it but is not
     right either. 92 clears it by 15px there, and by more on a device with an
     indicator, because .shell-nav absorbs the safe area into its own padding
     while this adds it on top. tests/sw-update-toast.mjs measures the gap so a
     taller nav cannot quietly close it again. */
  bottom: calc(env(safe-area-inset-bottom, 0px) + 92px);
  margin-inline: auto;
  max-width: 420px;
  z-index: 90;
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 10px 8px;
  padding: 12px 12px 12px 16px;
  border-radius: var(--radius-lg);
  /* ============ THE WHOLE NOTICE IS INVERTED, AND THAT IS THE POINT ============
   *
   * IT WAS INVISIBLE BECAUSE IT WAS BUILT OUT OF THE APP'S OWN MATERIALS.
   * Measured on the dashboard at 390px: the notice was --surface-raised, which
   * is EXACTLY the colour of the stat tiles it lands on top of (#2A2528 on
   * Dark, #FFFFFF on Light — 1.00:1 against them), ringed by the same --border
   * hairline every card has, carrying a small --action button of the same
   * colour as Go Live above it and the Go Live pill in the nav below it. Three
   * purple controls were already on screen. A fourth, smaller one is not a
   * signal, and a raised card among raised cards is not a notice — it reads as
   * one more tile that happened to slide in.
   *
   * So the PLANE does the work, not the button. --text-primary as the fill and
   * --bg as the ink makes this the only thing on the screen at the opposite
   * polarity to everything else: a warm off-white panel on Dark, a near-black
   * one on Light, cream on Stage. Nothing else in the app looks like that, at
   * any width, on any theme.
   *
   * THIS IS AN ESTABLISHED DEVICE HERE, NOT A NEW ONE. .wrap-done in
   * css/dashboard.css already inverts for the same reason and says so: "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." Same trick, one level up — the panel instead of the
   * button.
   *
   * IT SURVIVES A THEME CHANGE BY CONSTRUCTION. The pair is the theme's own
   * text-on-background pair, so its contrast is whatever that theme already
   * guarantees for body copy: 14.75:1 on Dark, 15.07:1 on Light. A fourth
   * theme brings its own inversion with it and nothing here needs touching.
   *
   * WHY NOT BLUE. Mark asked for blue. Blue with a left-to-right gradient is
   * the AI mark — --ai-a / --ai-b, "a fixed blue that no theme's --action can
   * ever be" — and tests/ai-treatment.mjs holds it, while
   * tests/sw-update-toast.mjs holds the other side of the same line by
   * refusing any gradient on this button. The mark labels things; it does not
   * do things. An update notice is not AI-powered and must not claim to be.
   *
   * WHY NOT THE NOTICE COLOURS, which would have been the obvious read of a
   * thing called .sw-update-notice: --notice-surface with a --notice edge is
   * already .toast.warning in css/dashboard.css, so this would have read as
   * "something went wrong" for a message that is good news; and on Stage
   * --notice and --action are both amber, so the treatment would evaporate on
   * exactly the theme used in the worst light. Rejected on both counts.
   * ========================================================================= */
  border: 1px solid var(--text-primary);
  background: var(--text-primary);
  color: var(--bg);
  box-shadow: var(--shadow-2);
  /* 14px, not 13. Two lines of prose that somebody is meant to act on should
     not be the smallest type on the screen. */
  font-size: 14px;
  line-height: 1.45;
}
.sw-update-notice[hidden] { display: none; }
/* Full-width prose on row one, with the dismiss beside it. */
.sw-update-notice .swu-text {
  grid-column: 1;
  grid-row: 1;
  min-width: 0;
  align-self: center;
}

/* 44 BY 44, WHICH REVERSES AN EARLIER DECISION AND SAYS SO.
 *
 * It was 32px, with a note arguing that dismissing this is not a gig action and
 * that a full-size button next to the nav bar is a mis-tap waiting to happen.
 * The first half is true; the second is the wrong fix for it. A target under
 * 44px fails the size rule everything else in this app is held to, and the
 * mis-tap worry is answered by DISTANCE — the notice sits 76px above the nav —
 * rather than by making the target too small for a thumb.
 *
 * The GLYPH stays small. The box grows, not the ✕, so it does not become a
 * heavier-looking control than the one that actually does something. The
 * negative margins keep the visual padding of the card while the target
 * overhangs it. */
.sw-update-notice .swu-dismiss {
  grid-column: 2;
  grid-row: 1;
  align-self: start;
  width: 44px;
  height: 44px;
  margin: -10px -6px -10px 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 0;
  border-radius: var(--radius-md);
  background: transparent;
  /* THE PANEL'S OWN INK, because the panel is inverted. --text-secondary here
     would be the page's secondary tone laid on the page's text tone — 1.4:1 on
     Dark, and a dismiss control nobody can find. */
  color: var(--bg);
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
}
/* Hover and press are a wash of the panel's ink rather than --surface-sunken,
   which is #FFFFFF on Light and would have flashed a white square in the
   corner of a near-black panel. */
.sw-update-notice .swu-dismiss:hover { background: color-mix(in srgb, var(--bg) 14%, transparent); }
.sw-update-notice .swu-dismiss:active {
  background: color-mix(in srgb, var(--bg) 22%, transparent);
  transform: scale(0.94);
}
/* --focus-ring is the accent's light tint (#CFA3F7 on Dark), which on a cream
   panel is 1.5:1 — a focus ring that cannot be seen. On an inverted plane the
   ring has to be the plane's ink, which clears 14:1 either way round. */
.sw-update-notice .swu-dismiss:focus-visible { outline: 3px solid var(--bg); outline-offset: 2px; }

/* "Use it now". THE ONLY THING IN THE APP THAT RELOADS A PAGE SOMEBODY IS ON,
   so it is a real button with a real label rather than an icon: nobody should
   be able to reload their own half-filled Stripe form by mis-tapping a glyph.

   FILLED IN --action, WHICH IS THE APP'S OWN ACCENT, AND NOT A BLUE GRADIENT.
   Mark suggested blue with a gradient. Two things rule it out, and neither is a
   matter of taste:

     BLUE WITH A LEFT-TO-RIGHT GRADIENT IS ALREADY TAKEN. It is the AI mark —
     --ai-a / --ai-b, described in css/dashboard.css as "a fixed blue that no
     theme's --action can ever be", with tests/ai-treatment.mjs holding the
     separation. A blue gradient button here would read as "AI-powered", which
     an update notice is not.

     THE SAME NOTE ADDS: "it is NEVER a primary control. The mark labels things;
     it does not do things." This button does something.

   So it takes --action, filled, flat. Flat rather than gradient also keeps it
   clear of the tip control, which is --action painted TOP TO BOTTOM on a filled
   button — direction is one of the three axes those two are separated on.

   CONTRAST IS ALREADY GUARANTEED. scripts/check-contrast.mjs asserts
   action-text on action at AA for every app theme, so this pair cannot regress
   without that suite failing. Measured on Dark: 5.73:1.

   IT KEEPS --action EVEN THOUGH THE PANEL INVERTED, and that costs it a
   border. On an inverted plane the accent and the panel are the same polarity:
   #B36BEE on the Dark panel's #EAE4DD is 2.66:1, and #6D1FA8 on Light's
   #0C0B0A is 2.26:1 — both under the 3:1 WCAG 1.4.11 asks of the boundary of a
   control. Painting the button in the panel's ink instead would have cleared
   it at 14:1 and was tried and rejected: it makes the one thing to tap the one
   thing with no colour, on a screen where purple has meant "tap this"
   everywhere else. A 2px ring in --bg gives the fill a hard edge without
   touching the fill — 14.75:1 against the panel on Dark, 5.73:1 against the
   button — so the accent survives and the boundary clears.

   FULL WIDTH AND 48px, not a 108px chip in the corner. It was right-aligned
   and the same size as a form button, which on a panel this size reads as an
   afterthought. It is the only thing here anybody is meant to do. */
.sw-update-notice .swu-now {
  grid-column: 1 / -1;
  grid-row: 2;
  justify-self: stretch;
  min-height: 48px;
  padding: 10px 18px;
  border: 2px solid var(--bg);
  border-radius: var(--radius-md);
  background: var(--action);
  color: var(--action-text);
  font-family: inherit;
  font-size: 15px;
  font-weight: var(--weight-strong);
  white-space: nowrap;
  cursor: pointer;
}
.sw-update-notice .swu-now:hover { background: var(--action-hover); }
/* A PRESSED STATE THAT CAN BE SEEN, not just felt: the fill darkens and the
   button takes a small step down. Both, because a colour change alone is
   invisible to a thumb covering the button. */
.sw-update-notice .swu-now:active {
  background: var(--action-active);
  transform: translateY(1px);
}
/* Same reason as the dismiss: on an inverted panel the ring is the panel's
   ink. Offset 2px so it lands on the panel rather than on the button's own
   border, which is that colour already. */
.sw-update-notice .swu-now:focus-visible { outline: 3px solid var(--bg); outline-offset: 2px; }
@media (prefers-reduced-motion: no-preference) {
  .sw-update-notice .swu-now { transition: background 120ms ease; }
}

/* ------------------------------------------------ a card leaving the queue
 *
 * Confirmation that the tap registered, read at a glance between songs, not
 * decoration. Played goes green and left; Skipped goes red and right. The
 * height collapses alongside the slide so the rest of the queue settles into
 * the gap instead of snapping up into it.
 *
 * js/views/gigmode.js pins the height before adding these classes and waits
 * out the duration before repainting, so the card is still on screen while
 * this runs. Keep the two durations in step.
 *
 * The colours are the semantic tokens, so this reads correctly in Dark, Light
 * and Stage without any of them knowing it exists. */
.gm-card.is-leaving {
  overflow: hidden;
  pointer-events: none;
  animation: gm-card-leave 260ms cubic-bezier(.4, 0, 1, 1) forwards;
}
.gm-card.is-leaving-played {
  background: var(--positive-surface);
  border-color: var(--positive);
  --gm-leave-x: -18%;
}
.gm-card.is-leaving-skipped {
  background: var(--negative-surface);
  border-color: var(--negative);
  --gm-leave-x: 18%;
}

@keyframes gm-card-leave {
  /* The flash reads first, then the card goes. */
  35%  { opacity: 1; transform: translateX(0); }
  100% { opacity: 0; transform: translateX(var(--gm-leave-x, 0)); height: 0;
         margin-block: 0; padding-block: 0; border-width: 0; }
}

/* Reduce motion: the colour still says the tap landed, nothing travels.
   The class is set from JS off matchMedia rather than relying on a media
   query alone, because the duration JS waits for has to match the one the
   animation actually uses. */
.gm-card.is-leaving-still {
  animation-name: gm-card-leave-still;
  animation-duration: 160ms;
}
@keyframes gm-card-leave-still {
  40%  { opacity: 1; }
  100% { opacity: 0; height: 0; margin-block: 0; padding-block: 0; border-width: 0; }
}

/* The 400px rule that hid the pencil is gone with the pencil. It existed
   because two chips would not fit beside a 94px stacked actions column on an
   iPhone SE; there is one chip now and the actions are a 44px row. */

/* SCROLL ANCHORING, WHICH IS NOT A LAYOUT PROBLEM AND LOOKS EXACTLY LIKE ONE.
 *
 * Adding the chart link moved the queue's scroll by 4px when a card animated
 * out — with the card heights, the scroll height and the maximum scroll all
 * measured as byte-identical with the control present and absent. Nothing got
 * taller. What changed is which node Chrome picks as its scroll anchor: the
 * <a> inside each card is a new candidate, and when the card holding the
 * anchor is removed the browser compensates by shifting the scroll.
 *
 * tests/live-reconcile.mjs holds the invariant that acting on one card moves
 * nothing else, and it is right to — a queue that creeps under the thumb
 * mid-set is how you skip the wrong song. overflow-anchor: none on the
 * control takes it out of the running as an anchor without touching the
 * scroller's own behaviour. */
.gm-chart-go { overflow-anchor: none; }
/* And on the scroller itself, because the anchor Chrome picks is chosen from
   the whole subtree: excluding the control only moves the choice to the next
   candidate inside the same card. The queue does not want anchoring at all —
   it is a list the musician positions by hand and which must stay exactly
   where they put it. */
.gm-scroll { overflow-anchor: none; }

/* 5. "END GIG" WAS THE NAMED FAULT ITSELF: a 900px-wide button with
      padding 1px 6px. It sets its own width and height and so never picked up
      the .btn-* base, and on a desktop it was a full-bleed control with an
      empty two-thirds of the screen beside it. */
.gm-end {
  padding: 8px 18px;
  max-width: 22rem;
  margin-inline: auto;
  display: block;
}

/* ---------------------------------------------------------------------------
   WHAT CAME IN TONIGHT

   A third figure in the counts strip, and the panel behind it.

   IT IS A BUTTON THAT DOES NOT LOOK LIKE ONE. The strip is a row of quiet
   statements and a filled button in the middle of it would shout over the
   queue, which is the thing on this screen that matters. So it carries the coin
   and the same weights as its two neighbours, and earns its tappability from
   the underline on the word rather than from a box.
   --------------------------------------------------------------------------- */
.gm-stat-tips {
  display: inline-flex;
  align-items: baseline;
  gap: 5px;
  /* 44px of target without 44px of visible chrome: the padding does the work
     and the surrounding row absorbs it. */
  min-height: 44px;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  font-size: var(--gm-t-body);
  color: var(--text-secondary);
  cursor: pointer;
}
.gm-stat-tips[hidden] { display: none; }
.gm-stat-tips > span { text-decoration: underline; text-underline-offset: 3px; }
.gm-stat-tips:hover > span { color: var(--text-primary); }


/* WHERE THE TOTAL CAME FROM, when request sales came in too: tips and requests,
   small and quiet under the figure, never competing with it. 19 September 2026. */
.gm-tipsheet-split {
  margin: 2px 0 6px;
  font-size: 14px;
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
}

.gm-tipsheet-list {
  list-style: none;
  margin: 0 0 calc(var(--gm-space) * 1.2);
  padding: 0;
  /* A generous night is a long list, and the panel must not grow past the
     screen it is centred in. */
  max-height: 46vh;
  overflow-y: auto;
}
.gm-tipsheet-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
  padding: 9px 0;
  border-bottom: 1px solid var(--border);
}
.gm-tipsheet-row:last-child { border-bottom: 0; }
/* EMPTY WHEN NOBODY GAVE A NAME, and empty on purpose. A tip with no request
   behind it has no name anywhere in the system, and "Anonymous" is a label
   nobody chose. The amount stands on its own. */
.gm-tipsheet-who {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--text-secondary);
}
/* WHO, AND WHAT THEY ASKED FOR, stacked so the row stays two columns and the
   amount stays on the baseline of the name rather than of the line under it. */
.gm-tipsheet-side {
  display: flex;
  flex-direction: column;
  gap: 1px;
  min-width: 0;
}
.gm-tipsheet-asked {
  color: var(--text-muted);
  font-size: var(--gm-t-micro);
}
.gm-tipsheet-amount {
  font-weight: var(--weight-strong);
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

/* ---- the five minute warning, counting ----
 *
 * THE TIME IS THE HEADING NOW. It used to read "Five minutes left" and go on
 * saying that for five minutes; the one number on the panel was the one number
 * that could not be trusted. So the figure is the largest thing on it, because
 * this is read at arm's length off a stand in a room lit for the audience, and
 * the word beside it is the small half of the sentence rather than the other
 * way round.
 *
 * tabular-nums so the panel does not twitch as the digits change — at one
 * update a second, proportional figures make the whole heading shuffle. */
.gm-extend-head {
  display: flex;
  align-items: baseline;
  justify-content: center;
  gap: 8px;
  margin: 0 0 10px;
}
.gm-extend-clock {
  font-size: clamp(34px, 11vw, 46px);
  font-weight: var(--weight-strong);
  line-height: 1;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.01em;
}
.gm-extend-word {
  font-size: var(--gm-t-mid);
  font-weight: var(--weight-normal);
  color: var(--text-secondary);
}

/* A GAUGE, NOT AN ANIMATION. Its width is set from the real remaining fraction
 * once a second, so it cannot disagree with the figure above it and cannot go
 * on running against a duration somebody has just extended.
 *
 * Over five minutes it moves a third of a per cent a second — imperceptible
 * frame to frame, and obviously different when you look back up from the guitar,
 * which is exactly the reading this is for.
 *
 * NO prefers-reduced-motion RULE IS NEEDED and that is a property of the design
 * rather than an omission: there is no continuous animation to suppress. The
 * transition below is 900ms of easing on a once-a-second step, which is a
 * settle rather than a movement; it is still dropped under reduced motion
 * because the honest thing is to let the value jump. */
.gm-extend-bar {
  height: 4px;
  margin: 0 auto 14px;
  max-width: 15rem;
  border-radius: 999px;
  background: var(--surface-sunken);
  overflow: hidden;
}
.gm-extend-bar i {
  display: block;
  height: 100%;
  border-radius: 999px;
  background: var(--notice);
  transform: scaleX(1);
  transform-origin: left center;
  transition: transform .9s linear;
}
@media (prefers-reduced-motion: reduce) {
  .gm-extend-bar i { transition: none; }
}

/* ------------------------------------------- changing when the gig ends */

/* THE END TIME IS A READING AGAIN, and the countdown is the control.
 *
 * Mark: the End time "looks out of alignment with the other times, probably
 * because it became tappable". It did, and that was exactly why, though not for
 * the obvious reason. The row itself is align-items:center, so a taller item
 * would normally still centre. But .gm-tpair is align-items:BASELINE, and a
 * baseline-aligned flex container that is taller than its content puts that
 * content at the TOP and leaves the slack underneath. So min-height:44px, added
 * to make the tap target honest, lifted the End time above the clock chip
 * beside it by about twelve pixels. Nothing else in this bar is 44px.
 *
 * Taking the button away takes the 44px away and the three figures line up.
 * The same trap is waiting for the countdown, which is why its rule below pays
 * for its height in padding rather than in min-height.
 *
 * There is no rule left here at all: .gm-t-end is now plain .gm-tpair, which is
 * what the Start time beside it has always been. The only thing that survives
 * is the 360px rule further up that hides it on the narrowest phones. */

/* THE COUNTDOWN IS THE TAP TARGET.
 *
 * Same principles as the End time's button had — no fill, no border at rest,
 * 44px of honest height — plus the one thing that button did not need.
 *
 * MIN-WIDTH IS LOAD-BEARING. This figure goes "1h 05m" to "43m" to "0:00" over
 * a set, and a target that narrows as it counts down is at its smallest exactly
 * when somebody most wants to press it. 6.5ch holds the widest form, so the
 * button is the same size at nought minutes as at ninety. It was the objection
 * to putting the control here in the first place; this is the answer to it
 * rather than a reason to put the control somewhere less natural. */
button.gm-remaining {
  appearance: none;
  background: none;
  border: 0;
  /* PADDING, NOT min-height, AND THAT IS THE WHOLE ALIGNMENT FIX. This is
     align-items:baseline like the readings beside it, and a baseline container
     taller than its content tops the content and leaves the slack below — which
     is precisely what threw the End time out of line. Paying for the height in
     symmetrical padding keeps the numerals on the same line as the clock chip
     while still giving 44px to aim at. The negative margin gives the row back
     the space, so nothing else moves. */
  padding: 11px 6px;
  /* margin-left IS NOT SET HERE. The shorthand would reset it, and .gm-remaining
     above sets `margin-left: auto` — which is how the End time and the countdown
     were supposed to travel to the right edge together. This rule quietly threw
     that away and left the whole row packed left with 701px of slack beside it
     at desktop. The auto margin now lives on .gm-t-end, which is the first of
     the pair, so both move. */
  margin-top: -11px;
  margin-right: -6px;
  margin-bottom: -11px;
  min-height: 44px;
  min-width: calc(6.5ch + 12px);
  justify-content: flex-end;
  border-radius: var(--radius);
  /* NOT `font: inherit`. The shorthand discards every font property it does not
     name, including the font-size and font-weight .gm-remaining sets two rules
     up — so the countdown rendered 16px normal instead of 19px semibold and
     became typographically IDENTICAL to the End time beside it at every width
     above 560px. The control and the reading were indistinguishable, which is
     the one thing this arrangement had to get right. */
  font-family: inherit;
  line-height: inherit;
  /* CLEARLY THE LARGEST THING IN THE ROW. It is the control, and the three
     readings to its left are 16px; at --gm-t-large it was 19px, which is a
     difference you have to look for. This is the figure a musician glances at
     mid-song and the one they press. */
  font-size: calc(var(--gm-t-large) * 1.25);
  font-weight: var(--weight-strong);
  cursor: pointer;
}
/* Underlined rather than boxed. The one hint that this figure is different from
   the two beside it, at the weight the theme rules allow. The rule is under the
   numerals only: "left" is a unit, not part of the control's name. */
button.gm-remaining > span:first-child {
  text-decoration: underline;
  /* currentColor at an alpha, NOT --border-strong. That token is near-black on
     Light and mid-grey on Dark, so the same hint measured 13.57:1 against the
     bar on Light and 3.77:1 on Dark — a hairline hint on one theme and a rule
     at full text weight on another. An alpha of the ink itself is the same
     relative weight in all three. */
  text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
  text-underline-offset: 3px;
}
/* THE UNDERLINE IS THE WHOLE AFFORDANCE, so it has to clear 3:1 like any other
   line that carries meaning. Sampled off the shipped screenshot it measured
   2.98:1 on Light — under the floor, on the one control on this row that a
   musician presses, on the theme used in daylight. There is no hover on a
   phone and no fill to fall back on. 2px and the full text colour: thickness
   is the half of it that survives glare. */
button.gm-remaining > span:first-child {
  text-decoration-color: currentColor;
  text-decoration-thickness: 2px;
}
button.gm-remaining:hover > span:first-child { text-decoration-color: var(--action-on-surface); }
button.gm-remaining:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }

/* CLOSE IS NOT THE BIGGEST THING HERE. It borrowed .gm-peek-close, designed for
   a panel where dismissing is the only action; this one has four actions. On
   Dark and Stage its fill matched the panel exactly, so the largest control on
   the panel was also an invisible one. */
.gm-endpanel .gm-peek-close {
  width: auto;
  min-width: 96px;
  min-height: 44px;
  margin: 0 auto;
  display: block;
  background: none;
  border: 1px solid var(--border-strong);
  color: var(--text-secondary);
}

/* Sentence case, at reading size. The sub-line was .gm-peek-who - 13px,
   uppercase, wide tracking - which is a label style, and a sentence set as a
   label reads as an imperative. */
.gm-endsub {
  margin: 0 0 calc(var(--gm-space) * 0.8);
  color: var(--text-secondary);
  font-size: var(--gm-t-body, 15px);
}

.gm-endpanel .gm-endsteps {
  display: flex;
  gap: 8px;
  margin: 0 0 calc(var(--gm-space) * 0.9);
}
.gm-endstep {
  flex: 1;
  min-height: 48px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--surface-raised);
  color: var(--text-primary);
  font: inherit;
  font-weight: var(--weight-medium);
  font-variant-numeric: tabular-nums;
  cursor: pointer;
}
.gm-endstep:hover { border-color: var(--action-on-surface); color: var(--action-on-surface); }
.gm-endstep:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }

.gm-endat { display: flex; flex-direction: column; gap: 6px; margin: 0 0 var(--gm-space); }
.gm-endat-label {
  font-size: var(--gm-t-micro);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-secondary);
}
.gm-endat-row { display: flex; gap: 8px; }
.gm-endat-row .gm-input { flex: 1; min-height: 48px; }
/* THE ONE PRIMARY ON THE PANEL. Every control here used to be the same
   treatment - three increments, Set, and a Close that was the LARGEST of them
   at full width - so dismissing outranked every action. Set is what the field
   beside it exists for, so Set is filled. */
.gm-endat-set {
  flex: none;
  min-height: 48px;
  padding: 0 18px;
  border: 1px solid transparent;
  border-radius: var(--radius);
  background: var(--action);
  color: var(--action-text);
  font-family: inherit;
  font-size: inherit;
  font-weight: var(--weight-strong);
  cursor: pointer;
}
.gm-endat-set:hover { background: var(--action-hover); }
.gm-endat-set:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }

/* ------------------------------ the gig you can still go back to, on Go Live */

/* ABOVE THE VENUE FIELD, because starting a NEW gig is the wrong answer to
   "where has my gig gone". Outlined rather than filled: Go Live is the primary
   action on this screen and stays the loudest thing on it. */
/* .gm-back and its four children stood here: the "your last gig is still open"
   panel on the Go Live form. Removed on 26 August with the markup that used
   them. The way back into a finished gig is on the dashboard now, inside that
   gig's own card, and only while there genuinely is one. */

/* ============ A SONG THE MUSICIAN PUT IN THEMSELVES ============
 *
 * Same slot and the same shape as NEW and the played chip, on the second row
 * of the card, because what has happened to a request belongs on the line
 * under which song it is.
 *
 * NOT A COLOUR ON ITS OWN. This screen is read at arm's length from a stand in
 * a room lit for the audience, so it is a word in a bordered chip and the
 * border is what carries at that distance.
 *
 * QUIET, DELIBERATELY. It is a fact about the card rather than something that
 * has happened, and a queue where the musician has added six songs should not
 * be six shouts. */
/* ============ AND IT IS THE APP'S OWN COLOUR, NOT A GREY CHIP ============
 *
 * A grey outlined pill among a card's other grey outlined pills is not a mark,
 * it is a word. Mid-song, at arm's length, from a stand, a musician has to be
 * able to tell their own entries from the room's in one movement, and the
 * fastest way to do that on a stack of near-identical rectangles is a bar down
 * the leading edge: it reads peripherally and it costs no width, which matters
 * on the one screen where width is the scarce thing.
 *
 * NOT GREEN, though green was suggested. Green already means "on your list" on
 * the audience's search rows, and it was taken off the songbook cards for
 * reading wrong. --action is the app's own accent, it is nothing else's on
 * this screen, and "the app's colour" is a fair reading of "I put this here".
 * Red now means stop, on this same screen, one tap away.
 *
 * THE BAR IS NOT THE WHOLE SIGNAL. It is a colour, and the room is lit for the
 * audience: the pill still says the word, so nothing here depends on seeing a
 * hue at four in the afternoon through a window. */
.gm-card.is-mine {
  box-shadow: inset 3px 0 0 0 var(--action);
}
.gm-mine {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 1px 8px;
  border: 1px solid transparent;
  border-radius: 999px;
  background: var(--action-quiet);
  color: var(--action-on-surface, var(--action));
  font-size: .78em;
  font-weight: var(--weight-medium, 500);
  letter-spacing: .02em;
  white-space: nowrap;
}
/* Whoever the musician noted when they put it in. Inside the pill, because it
   is a fact about their entry rather than a person who asked. */
.gm-mine-for {
  padding-left: 5px;
  border-left: 1px solid currentColor;
  color: var(--text-secondary);
  font-weight: var(--weight-normal, 400);
}

/* ============ PUTTING ONE IN YOURSELF ============
 *
 * At the top of the scroller, above the queue, because that is where the thumb
 * already is when the screen is held one-handed and because what it produces
 * appears directly underneath it.
 *
 * 52px, which is above the 44 this app holds everything else to, because this
 * one is hit mid-song by somebody with an instrument in the other hand. */
/* ============ THEY HAVE TO READ AS CONTROLS IN A DARK ROOM ============
 * A reviewer measured them as a 1px DASHED outline at 3.77:1 against the page
 * and said they read as two floating text labels rather than as buttons. A
 * dashed hairline under 4:1 does not survive a stage light on the screen.
 * Solid, on the strong border token, with the label in the primary ink. */
.gm-addsong {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  width: 100%;
  min-height: 52px;
  margin: 0 0 10px;
  border: 1px solid var(--border-strong, var(--border));
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--text-primary);
  font-size: 1rem;
  cursor: pointer;
}
.gm-addsong:hover { border-color: var(--action); }
.gm-addsong:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }

/* ============ THE TWO CONTROLS ABOVE THE QUEUE ============
 *
 * The markup has said "TWO CONTROLS, SIDE BY SIDE" since it was written and
 * .gm-stagerow had no rule at all, so the two buttons stacked as blocks and
 * cost a hundred and twenty pixels above the queue on a 390px phone: the
 * reported "the entry button is in the way of the request cards". One row,
 * which is what the comment beside it always described.
 *
 * They stay above the queue rather than moving into the bar. Both produce
 * something that appears directly underneath them, both are reached with the
 * thumb already resting there, and the bar is outside the scroller so a row
 * added to it costs the queue exactly the same height while being harder to
 * hit. What was wrong was the height, not the place. */
.gm-stagerow {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 8px;
  /* THE CARDS' OWN COLUMN. .gm-queue is capped at 900 and centred; this ran
     the full width, so on a 1440 laptop each button was 697px wide and the
     pair overhung the list they belong to by 250px on each side. */
  max-width: 900px;
  margin: 0 auto;
  width: 100%;
}
/* The buttons carry their own bottom margin from .gm-addsong; the row owns the
   spacing now so the two cannot disagree about it. */
.gm-stagerow .gm-addsong { margin-bottom: 10px; }

/* ============ THE QUEUE ORDER, AS A CHIP IN A ROW THAT ALREADY EXISTS ======
 *
 * This was a full-width block above the queue: a labelled select, a switch with
 * its name beside it, and a line of explanation, sitting directly over the place
 * requests land. It is now a chip at the right-hand end of the counts strip.
 *
 * IT ADDS NO HEIGHT, and that is the whole design constraint. .gm-counts is a
 * flex row holding two or three short figures with its right half empty; the
 * chip is pushed into that space with margin-left:auto and is deliberately
 * shorter than the strip's own line box, so the row it joins is the row that was
 * already there.
 *
 * THE TAP TARGET IS 44px AND THE CHIP IS NOT. The ::before below reaches out
 * beyond the chip in both directions. It is positioned, so it takes no space and
 * cannot push the row taller; it is what a thumb actually hits mid-gig.
 *
 * EVERY COLOUR IS A TOKEN, so it follows the theme like the rest of the app. The
 * menu is the theme menu's shape (.gm-theme-menu) with its own names, because
 * two popovers on one screen should not be two designs. */
/* ============ IT MUST FIT INSIDE THE LINE THAT IS ALREADY THERE ============
 *
 * .gm-counts is a flex row with the default align-items:stretch, so the row is as
 * tall as its TALLEST item and every other item is stretched to match. The first
 * version of this chip was 23.2px against a natural stat of 18.2px, and the
 * result was not a taller chip: it was two taller figures and a strip 5px deeper,
 * measured against the build from before the sort existed. The chip was never the
 * thing that looked wrong and that is exactly why it took measuring to find.
 *
 * So the chip's whole box, border included, stays inside the stat's line box.
 * align-self:center rather than stretch, so it is centred on that line rather
 * than pulled to fill it. tests/live-page-height.mjs holds the number. */
.gm-counts-end {
  margin-left: auto;
  display: flex;
  align-items: center;
  align-self: center;
}
.gm-sortchip-wrap { position: relative; }
.gm-sortchip {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  /* EVERY ONE OF THESE FOUR NUMBERS IS LOAD-BEARING, and together they have to
     come to no more than the height of a .gm-stat beside it: 1.15 of 12px is
     13.8, plus 1px of padding, plus 2px of border, is 16.8 against 18.2. */
  padding: 0 7px 1px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: transparent;
  color: var(--text-secondary);
  font: inherit;
  font-size: inherit;
  line-height: 1.15;
  cursor: pointer;
  white-space: nowrap;
}
/* THE HIT AREA, NOT THE CHIP. Positioned so it costs no layout at all. */
.gm-sortchip::before {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: max(100%, 44px);
  height: 44px;
  transform: translate(-50%, -50%);
}
.gm-sortchip:hover { border-color: var(--border-strong); color: var(--text-primary); }
/* ABOVE THE BACKDROP WHILE IT IS OPEN, so a second tap on the chip closes the
   menu. Without this the backdrop covers the chip and swallows that tap: the
   menu still closed, because the backdrop is what closes it, but the chip could
   not be used to toggle and the aria-expanded state it advertises was a control
   nothing could press. */
.gm-sortchip[aria-expanded="true"] {
  position: relative;
  z-index: 42;
  border-color: var(--action);
  color: var(--text-primary);
}
.gm-sortchip:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }
.gm-sortchip-caret {
  width: 0;
  height: 0;
  border-left: 3.5px solid transparent;
  border-right: 3.5px solid transparent;
  border-top: 4px solid currentColor;
  flex: none;
}

/* The backdrop catches the tap that dismisses it, and is invisible: a scrim
   over a live queue would darken the thing being read. */
.gm-sortchip-backdrop {
  position: fixed;
  inset: 0;
  z-index: 40;
}
.gm-sortchip-menu {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 41;
  min-width: 208px;
  padding: 6px;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius, 10px);
  background: var(--surface-raised);
  box-shadow: 0 10px 30px rgb(0 0 0 / 35%);
}
.gm-sortchip-menu[hidden] { display: none; }
.gm-sortchip-title {
  margin: 2px 8px 6px;
  color: var(--text-muted);
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
.gm-sortchip-option {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 100%;
  min-height: 40px;
  padding: 6px 8px;
  border: 0;
  border-radius: 8px;
  background: transparent;
  color: var(--text-primary);
  font: inherit;
  font-size: 0.9rem;
  text-align: left;
  cursor: pointer;
}
.gm-sortchip-option:hover { background: var(--action-quiet, var(--surface)); }
.gm-sortchip-option:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: -3px; }
.gm-sortchip-opt-name { flex: 1 1 auto; }
/* The tick holds its space whether or not it is shown, so choosing an option
   does not shuffle the names sideways. */
.gm-sortchip-opt-tick { flex: none; opacity: 0; color: var(--action); }
.gm-sortchip-option.is-on .gm-sortchip-opt-tick { opacity: 1; }
/* THE ONE DIVIDER IN THE MENU. The five orders are one choice; the multiples
   switch is a different question about the same list, and a hairline is enough
   to say so without a second heading. */
.gm-sortchip-multi {
  margin-top: 4px;
  padding-top: 10px;
  border-top: 1px solid var(--border);
  border-radius: 0 0 8px 8px;
}

/* ============ AND THE MARK THAT SOMETHING IS LEFT ON ============
 * paintRoomMark() has been toggling .is-on since the panel was written and no
 * stylesheet has ever drawn it, so a musician with a message showing or
 * requests stopped had nothing on the queue telling them so. A filled dot and
 * the accent border: a colour on its own would be no use in a room lit for the
 * audience, so the dot is a shape as well as a hue. */
.gm-room-open.is-on {
  border-style: solid;
  border-color: var(--action);
  color: var(--text-primary);
}
.gm-room-open.is-on::after {
  content: '';
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--action);
}

/* ============ WHAT THE ROOM SEES ============
 *
 * The panel had NO STYLESHEET AT ALL. It inherited .gm-peek's centring, so
 * every label, hint, field and button sat in one centred column with nothing
 * separating the three things it does, and the requests switch rendered as a
 * bare browser checkbox: a large blue system tick, the one control in the app
 * that looked like it came from a different app.
 *
 * Ranged left, three blocks with a rule between them, one label each and the
 * hints cut to the two that carry a fact the screen does not. */
.gm-roompanel {
  width: min(420px, 100%);
  padding: 14px 16px 16px;
  text-align: left;
}
.gm-room-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 4px;
}
.gm-room-title {
  margin: 0;
  font-size: var(--gm-t-title, 1.05rem);
  font-weight: var(--weight-strong);
  color: var(--text-primary);
}
/* The way out, in the corner every panel on this screen puts it. */
.gm-room-x {
  flex: none;
  width: 44px;
  height: 44px;
  margin: -10px -12px 0 0;
  display: grid;
  place-items: center;
  background: transparent;
  border: 0;
  border-radius: var(--radius);
  color: var(--text-secondary);
  font-size: 17px;
  line-height: 1;
  cursor: pointer;
}
.gm-room-x:hover { color: var(--text-primary); }
.gm-room-x:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }

/* THREE BLOCKS, AND YOU CAN SEE WHERE ONE ENDS. They ran together as one column
   of prose, which is why the panel read as long: it was not only the number of
   words, it was that nothing said which words belonged to which control. */
.gm-room-block {
  padding: 14px 0 0;
  margin-top: 14px;
  border-top: 1px solid var(--border);
}
.gm-room-block:first-of-type { padding-top: 0; margin-top: 0; border-top: 0; }
.gm-room-label {
  display: block;
  margin-bottom: 8px;
  font-size: .8rem;
  font-weight: var(--weight-strong);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--text-secondary);
}
.gm-room-hint {
  margin: 8px 0 0;
  font-size: .85rem;
  line-height: 1.35;
  color: var(--text-secondary);
}
.gm-room-state {
  margin: 8px 0 0;
  font-size: .85rem;
  color: var(--text-muted, var(--text-secondary));
}
/* The state that IS something, rather than the state that is nothing. */
.gm-room-state.is-on { color: var(--action-on-surface, var(--action)); }
.gm-room-working {
  margin: 12px 0 0;
  font-size: .85rem;
  color: var(--text-secondary);
  min-height: 1.2em;
}
.gm-room-working:empty { margin: 0; min-height: 0; }

/* THE PRESETS FILL THE BOX, THEY DO NOT SEND. Small, quiet and wrapping,
   because they are a shortcut to typing rather than four more decisions. */
.gm-room-presets {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-bottom: 8px;
}
/* ============ 44, BECAUSE THIS IS A STAGE SCREEN ============
 * The dashboard's own pills are smaller and are right to be: they are tapped
 * sitting down. Everything on this screen is hit mid-set by somebody with an
 * instrument in the other hand, which is why .gm-addsong is 52 and the note
 * beside it says so. The presets were 34 and the app's toggle is 30 by
 * default; both are brought up to the floor this screen holds rather than
 * inheriting a number chosen for a desk. */
.gm-room-preset {
  min-height: 44px;
  padding: 0 13px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: transparent;
  color: var(--text-secondary);
  font-size: .85rem;
  cursor: pointer;
}
.gm-room-preset:hover { color: var(--text-primary); border-color: var(--text-secondary); }
.gm-room-preset:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }

/* ============ EACH ONE CAN BE REMOVED, AND THE TWO ARE SEPARATE TARGETS ====
 * A chip fills the box; the mark beside it takes the chip off the list. Two
 * buttons rather than a long press or an edit mode: a mode is one more thing
 * to be in mid-set, and a long press is invisible. They share a border so the
 * pair reads as one chip, and the divider between them says where one target
 * ends and the other begins. */
.gm-room-preset-wrap {
  display: inline-flex;
  align-items: stretch;
  border: 1px solid var(--border);
  border-radius: 999px;
  overflow: hidden;
}
.gm-room-preset-wrap .gm-room-preset {
  border: 0;
  border-radius: 0;
  padding-left: 11px;
  padding-right: 8px;
}
.gm-room-preset-x {
  flex: none;
  /* 34 rather than 44: the WRAP is what holds the 44px height this screen
     asks of everything, and a 44px-wide remove beside every chip pushed even
     the short ones onto a row of their own. The target is still 34 by 44,
     which is above the floor in the dimension a thumb misses in. */
  width: 34px;
  min-height: 44px;
  display: grid;
  place-items: center;
  padding: 0;
  border: 0;
  border-left: 1px solid var(--border);
  background: transparent;
  color: var(--text-muted, var(--text-secondary));
  font-size: 13px;
  line-height: 1;
  cursor: pointer;
}
/* QUIETER THAN THE CHIP IT SITS IN. The body of a chip fills the message box
   and is what a musician means to press; this only removes it from the list.
   A reviewer watching the two at arm's length said the mark was the only thing
   on a saved chip that looked pressable, which is the wrong way round. */
.gm-room-preset-x:hover { color: var(--negative, var(--text-primary)); opacity: 1; }
.gm-room-preset-x:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: -3px; }

/* .gm-room-msgacts held a Take it down and a Save this one under the message
   box. Both are gone: the save control moved up beside the chips it makes, and
   the removal moved onto the message itself. */

/* ============ THE TWO SUB-LABELS ============
 * A message on the audience page right now and a message stored for later are
 * nearly the same object and mean completely different things. Quieter and
 * smaller than the block's own label, because they divide it rather than
 * heading it. */
.gm-room-sublabel {
  margin: 12px 0 6px;
  font-size: .78rem;
  font-weight: var(--weight-strong);
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  /* NOT --text-muted. A reviewer put it plainly: the two labels carrying the
     entire meaning of this block were the dimmest text in it, dimmer than the
     pills they label, and the first thing to go under glare or stage spill. */
  color: var(--text-secondary);
}
.gm-room-sublabel:first-of-type { margin-top: 8px; }

/* ============ THE MESSAGE THE ROOM IS READING RIGHT NOW ============
 *
 * The same pill shape as a saved one, and deliberately not the same weight.
 * Four things separate them and only one is colour, because this panel is read
 * in a room lit for the audience:
 *   it is FILLED where a saved chip is outlined
 *   it carries a DOT, which nothing else in this block has
 *   it sits under its own sub-label saying it is on the page now
 *   and it is on a row of its own, above the saved ones
 *
 * ITS DISMISS IS A ROW-LEVEL TARGET, not a mark inside the pill. One tap with
 * no confirmation takes a message off a room, so it is 44 by 44, it is set
 * apart from the pill by a real gap, and it is nowhere near the run of saved
 * chips underneath where a thumb is already working. */
.gm-showing {
  display: flex;
  align-items: center;
  gap: 12px;
  /* RULED OFF FROM THE SAVED ONES, and given real air. The two dismisses were
     56px apart with nothing between them; the rule and the SAVED label now
     sit in that gap and it is wider. */
  margin: 0 0 4px;
  padding-bottom: 18px;
  border-bottom: 1px solid var(--border);
}
.gm-showing-pill {
  flex: 0 1 auto;
  min-width: 0;
  display: inline-flex;
  align-items: center;
  /* A wrapped message makes the pill taller, and its own corner radius has to
     stop being a lozenge when it is three lines deep. */
  border-radius: 22px;
  gap: 8px;
  min-height: 44px;
  padding: 6px 14px;
  border-radius: 999px;
  background: var(--action-quiet, var(--surface-raised));
  border: 1px solid var(--action);
  color: var(--text-primary);
  font-size: .95rem;
  font-weight: var(--weight-medium, 500);
}
.gm-showing-dot {
  flex: none;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--action);
}
/* ============ IT WRAPS. IT DOES NOT TRUNCATE ============
 * It ellipsed at 120 characters, at every width including the laptop, so at
 * the longest message the field allows this block could not tell a musician
 * what their audience was reading. That is the one job it has. Three lines of
 * a pill costs nothing next to not being able to read the thing. */
.gm-showing-text {
  min-width: 0;
  overflow-wrap: anywhere;
}
/* ============ AND IT DOES NOT LOOK LIKE THE OTHER DISMISS ============
 *
 * A reviewer named this the central defect and was right: this mark and the
 * one inside a saved chip were the same glyph at the same size in the same
 * colour, about 56px apart, and they do completely different things. Missing
 * upward takes a message off the room mid-song; missing downward quietly
 * deletes a saved one. One hand, dark, at arm's length.
 *
 * So this one is in the negative tone AT REST rather than only on hover, it is
 * a filled tint rather than an outline, and the saved chips' marks are quieter
 * than they were. Colour is not carrying it alone: this is 44 square against
 * their 34 by 44, it is outside its pill where theirs are inside, and the row
 * it sits on is ruled off from them. */
.gm-showing-x {
  flex: none;
  width: 44px;
  height: 44px;
  display: grid;
  place-items: center;
  padding: 0;
  border: 1px solid var(--negative);
  border-radius: var(--radius);
  /* --surface-raised, NOT a negative tint. There is no --negative-quiet token
     in this app: the first attempt asked for one and silently fell through to
     `transparent`, which is exactly what the saved chips' mark already has, so
     the two still shared a background and the check said so. A raised ground
     against their transparent is a real difference and needs no token that
     does not exist. */
  background: var(--surface-raised, var(--surface));
  color: var(--negative);
  font-size: 17px;
  line-height: 1;
  cursor: pointer;
}
.gm-showing-x:hover { background: var(--negative); color: var(--bg); }
/* NOTHING ON THE PAGE IS A STATE, NOT AN EMPTY SPACE. Quiet, because nothing
   is happening, and present, because a musician has to be able to read that
   nothing is happening. */
.gm-showing.is-none { padding-bottom: 14px; }
.gm-showing-none {
  font-size: .95rem;
  color: var(--text-muted, var(--text-secondary));
}
.gm-showing-x:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }

/* ============ AND THE CONTROL THAT SAVES ONE ============
 * The last thing in the row of saved chips, because that is what it makes.
 * Dashed against their solid, so it reads as "one more, not yet there" rather
 * than as a chip you could tap to fill the box. Small: beside a row of chips
 * it is a secondary action, and the panel already has a Save at its foot that
 * does something else entirely. */
/* ============ ITS OWN ROW, ALWAYS ============
 * At 390 it landed on the same row as the last chip's remove, about 25px from
 * it: create-a-saved-message and destroy-one, adjacent, at the width the gig
 * actually happens on. That is the same adjacency the live dismiss was moved
 * away from, reintroduced one row down. A full-basis flex item cannot share a
 * row with anything. */
.gm-room-preset-add {
  flex: 0 0 auto;
  /* ON ITS OWN ROW IS NOT ENOUGH ON ITS OWN. Below the chips it was nine
     pixels under the nearest remove, which for a thumb is the same adjacency
     turned through ninety degrees. The row break is the empty item above it,
     not a basis of 100% on this: a full basis makes the button full width,
     and clamping that back with max-width lets it rejoin the previous row. */
  margin-top: 14px;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-height: 44px;
  padding: 0 13px;
  border: 1px dashed var(--border-strong, var(--border));
  border-radius: 999px;
  background: transparent;
  color: var(--text-secondary);
  font-size: .85rem;
  cursor: pointer;
}
.gm-room-preset-add:hover:not(:disabled) {
  color: var(--text-primary);
  border-color: var(--action);
}
/* IT LOOKED IDENTICAL WITH AN EMPTY BOX AND A FULL ONE, so there was no way to
   tell it had nothing to act on, and the dashed edge reads as "inactive" in
   most vocabularies whether it is or not. Now it genuinely is inactive until
   there is something to save. */
.gm-room-preset-add:disabled { opacity: .4; cursor: default; }
.gm-room-preset-add:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }
/* Sits in the chip row so it wraps with them, and reads as a note rather than
   as something to press. */
/* A ZERO-HEIGHT FULL-WIDTH ITEM, which is the one thing that reliably ends a
   flex row without deciding how wide the next item is. */
.gm-room-rowbreak { flex: 0 0 100%; height: 0; }

/* ITS OWN LINE. Beside the button the row scanned as one sentence, "Nothing
   saved yet. + Add to saved", with the full stop almost touching it. */
.gm-room-nosaved {
  flex: 1 0 100%;
  margin-bottom: 2px;
  font-size: .85rem;
  color: var(--text-muted, var(--text-secondary));
}

.gm-room-field {
  width: 100%;
  min-height: 48px;
  padding: 0 12px;
  border: 1px solid var(--border-strong, var(--border));
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--text-primary);
  font-size: 1rem;
}
/* ============ THE WAY OUT, IN A PANEL THAT IS NOT ABOUT LEAVING ============
 * End gig is drawn in three places and is the same control in all three. In
 * the two panels it is quiet: ruled off from what the panel is actually for,
 * outlined rather than filled, and never the loudest thing on the screen it is
 * on. It still arms, still counts down and still needs a second tap, because
 * it is the same markup and the same handler.
 * The one at the foot of the queue is untouched: its position is settled. */
.gm-room-exit {
  display: flex;
  justify-content: center;
  margin-top: 14px;
  padding-top: 12px;
  border-top: 1px solid var(--border);
}
.gm-end.gm-end-quiet {
  /* NOT FULL WIDTH, and the first version was. Stretched across the panel it
     came out at 17696 square pixels against Save's 16432, and bigger than
     every add-time button in the time panel put together: the loudest control
     on both screens was the one for leaving. It hugs its own words now.
     44 stays, because that is the floor this screen holds everything to and a
     control being quiet is not a reason to make it hard to hit deliberately. */
  width: auto;
  min-height: 44px;
  padding: 0 20px;
  background: transparent;
  border: 1px solid var(--border-strong, var(--border));
  color: var(--text-secondary);
  font-size: .95rem;
}
.gm-end.gm-end-quiet:hover {
  border-color: var(--negative, var(--border-strong));
  color: var(--text-primary);
}
/* ARMED, IT STOPS BEING QUIET. The whole point of the second tap is that the
   musician can see the state they are in, and a control that looked identical
   armed and unarmed would be a confirmation nobody could read. */
.gm-end.gm-end-quiet.is-armed {
  border-color: var(--negative, var(--action));
  color: var(--negative-ink, var(--text-primary));
  background: var(--negative-quiet, transparent);
}

/* ============ THE MESSAGE IS IN A BOX AND EVERYTHING ELSE IS NOT ==========
 *
 * THE PANEL DOES TWO DIFFERENT THINGS NOW AND THIS IS WHAT SAYS WHICH IS
 * WHICH. The switches above write the instant they move. The message cannot:
 * it broadcasts to every phone in the room, so it is held until a button is
 * pressed. Two behaviours in one panel look like an inconsistency unless the
 * shape accounts for them.
 *
 * SO THE DIFFERENCE IS DRAWN RATHER THAN WRITTEN. The instant controls sit
 * loose on the panel's own surface, ruled off from each other by hairlines, and
 * not one of them carries a button. This one is an inset card: its own sunken
 * surface, a full edge all the way round, and its button inside it. A box with
 * a button in it is not finished until the button is pressed, and nobody has to
 * be told that.
 *
 * THE PANEL'S FOOT HAS GONE. .gm-room-foot held a Save under every control,
 * which is what made the whole panel read as a form, which is the misreading
 * that lost Mark a setting on stage. There is no panel-level button any more.
 *
 * NO NEW COLOUR. --surface-sunken and --border are the two the panel already
 * uses; the card is made of a background and an edge the app already has. */
.gm-room-card {
  margin-top: 14px;
  padding: 14px;
  border: 1px solid var(--border-strong, var(--border));
  border-radius: var(--radius);
  background: var(--surface-sunken);
}
/* The hairline above every other block would double the card's own top edge. */
.gm-room-card.gm-room-block { border-top-color: var(--border-strong, var(--border)); }

.gm-room-save {
  width: 100%;
  min-height: 52px;
  margin-top: 12px;
  font-size: 1.05rem;
}
/* Beside the button rather than at the foot of the panel: a failure belongs
   next to the control that failed, and the line above the card is reporting the
   switches now. */
.gm-room-msgstate { margin: 12px 0 0; }

/* ============ LEAVING WITH A MESSAGE HALF WRITTEN ============
 *
 * FIXED AND OVER EVERYTHING, which is what every other question in this app
 * does (.vm-overlay on the profile form, the block panel here). Absolute inside
 * the panel was tried and is wrong twice over: .gm-roompanel scrolls, so inset:0
 * covers its content box rather than the screen and the card lands off-screen on
 * a scrolled panel; and z-index 61 is what it takes to sit above the panel's own
 * .gm-modal-overlay at 60.
 *
 * THREE ANSWERS STACKED, NOT SIDE BY SIDE. Side by side at 320px each button is
 * about 90px and "Put it on your page" wraps to three lines in the narrow
 * fonts. Stacked, each is full width, each clears 44px comfortably, and the
 * order reads as what it is: the thing most people came to do, the thing some
 * people want, and the way back. */
.gm-room-confirm {
  position: fixed;
  inset: 0;
  z-index: 61;
  display: grid;
  place-items: center;
  padding:
    calc(20px + var(--safe-top, 0px))
    calc(20px + var(--safe-right, 0px))
    calc(20px + var(--safe-bottom, 0px))
    calc(20px + var(--safe-left, 0px));
  background: var(--surface-overlay);
}
.gm-room-confirm-card {
  width: 100%;
  max-width: 22rem;
  padding: 18px;
  border-radius: var(--radius-lg, var(--radius));
  background: var(--surface-raised, var(--surface));
  box-shadow: 0 12px 32px rgba(0, 0, 0, .35);
}
.gm-room-confirm-title {
  margin: 0 0 6px;
  font-size: 1.05rem;
  line-height: 1.25;
}
.gm-room-confirm-body {
  margin: 0 0 14px;
  font-size: .9rem;
  line-height: 1.4;
  color: var(--text-secondary);
}
.gm-room-confirm-actions { display: grid; gap: 8px; }
.gm-room-confirm-actions button { width: 100%; min-height: 48px; }

/* ============ AND THE SAME CARD ON THE GO LIVE FORM ============
 *
 * 23 September 2026. The two questions the setup form asks back, tips going
 * off and paid requests going on, are the same object as the question above:
 * a title, a consequence, and answers stacked full width in the order "the
 * thing most people came to do, the thing some people want, and the way back".
 * They wear .gm-room-confirm-card and its three children unchanged, which is
 * why there is almost nothing here.
 *
 * THE SCRIM IS .gm-modal-overlay AND NOT .gm-room-confirm, which is the one
 * real difference. That one sits at z-index 61 because it has to cover the
 * Room controls panel; there is no panel under this, and .gm-modal-overlay
 * brings two things this needs that the other does not have: `place-items:
 * safe center`, so a card taller than a 320x568 handset in landscape cannot be
 * centred off the top edge, and a scrim that scrolls.
 *
 * IT ADDS NO HEIGHT TO THE FORM, which is the constraint that governs this
 * screen: the overlay is `position: fixed` and its slot in the form is empty
 * whenever no question is up. tests/golive-fits.mjs holds the Go Live button
 * on the screen at 320x568 and 375x667 either way.
 *
 * THE THREE-ANSWER CARD AT 320px. The scrim's padding is 20 a side, so the
 * card is 280 wide there and each answer is a full-width 48px row; nothing in
 * it is laid out across, so nothing in it can be clipped.
 *
 * AND THE SLOT ITSELF IS NOT A BOX WHEN IT IS EMPTY, which is the same rule
 * #gmVenueHint and #gmChargingSlot already follow a few hundred lines up: the
 * form is a flex column and an empty item in one is a hairline nobody asked
 * for on the screen with the least room to spare. */
#gmSetupConfirmSlot:empty { display: none; }

/* ============ THE CARD HAS AN EDGE OF ITS OWN IN BOTH THEMES ============
 *
 * 23 September 2026, measured by a reviewer and confirmed here. The card fill
 * is --surface-raised and in Dark that is #2A2528 against a page of #151214,
 * which is 1.24:1, or 1.32:1 once the scrim has darkened the page to #0B090B.
 * There was no border and the shadow is black on a black wash, so the scrim
 * was doing all the work and the card itself had almost no edge: it read as a
 * slightly paler patch rather than as a thing lying on top of the form. Light
 * is not in the same position, at 4.77:1 against the scrimmed page.
 *
 * THIS IS A SOLVED PROBLEM IN THIS FILE and the answer is copied rather than
 * invented: .gm-modal a few thousand lines up carries exactly this border for
 * exactly this reason, and its comment records measuring 1.17:1 on Dark before
 * it. One pixel of --border-strong is 3.05:1 against the card fill in Dark and
 * 4.02:1 against the scrimmed page, so the edge clears the 3:1 that WCAG
 * 1.4.11 asks of a boundary that carries meaning.
 *
 * DECLARED FOR BOTH THEMES, not only for Dark, for the same reason .gm-modal
 * does: a card that is drawn differently in two themes is two cards to keep in
 * step, and an edge Light does not strictly need costs it nothing.
 *
 * SCOPED TO THIS CARD. The Room controls panel's leave question wears the same
 * .gm-room-confirm-card and has the same missing edge, which is worth somebody
 * looking at, but it is a different screen and is not part of this change. */
.gm-setup-confirm-card { border: 1px solid var(--border-strong); }

/* ============ THREE ANSWERS, THREE TIERS ============
 *
 * See setupConfirmMarkup in js/views/gigmode.js for what two reviewers
 * measured: a filled button and two identical outlined ones, differing only in
 * the word. The stack reads loud, outlined, quiet now.
 *
 * THE MIDDLE TIER IS OUTLINED IN THE ACTION COLOUR and not in the default
 * --border-strong, which is the whole point of the change. That token is the
 * venue input's border, so an outlined button wearing it is, in Light, a white
 * box with a near-black hairline sitting under a purple button: a text field by
 * every visual convention the rest of this form uses. --action-on-surface is
 * the token for action colour drawn ON a surface, and it measures 7.34:1
 * against the card in Dark and 10.76:1 in Light, for both the line and the
 * word. Well clear of 4.5:1 for the text and of 3:1 for the border.
 *
 * THE QUIET TIER HAS NO BORDER AND NO FILL, which is what makes it the third
 * thing rather than a second copy of the middle one. It keeps .btn-quiet for
 * its size, its radius and its 48px reach and gives up only the line; the
 * label stays --text-primary, 11.94:1 in Dark and 19.67:1 in Light, because
 * this is the answer a musician takes in the dark and it is the one that holds
 * focus when the card opens.
 *
 * AND IT CARRIES THE EXTRA GAP. The row above it is "Open settings", the only
 * answer that leaves this screen minutes before a set, and eight pixels was
 * the whole of what stood between it and the answer a thumb goes to. Doubling
 * that one boundary is the separation; the tiers are the rest of it. */
.gm-setup-confirm-alt {
  border-color: var(--action-on-surface, var(--action));
  color: var(--action-on-surface, var(--action));
}
.gm-setup-confirm-quiet {
  border-color: transparent;
  color: var(--text-primary);
  margin-top: 8px;
}

/* ============ AND THE ANSWERS READ AT THE FORM'S OWN CHOICE SIZE ==========
 *
 * 23 September 2026. .btn-primary and friends are sized from --db-t-body,
 * which is the dashboard's 14px ramp, so these came out around 15px: the
 * smallest interactive type anywhere on this screen, on the control tapped
 * under the most pressure, under a form whose chosen values are 17 and a Go
 * Live button at 19. They take --gm-t-choice, which is the size of the text
 * inside the fields and of every answer on the form behind them.
 *
 * SCOPED UNDER .gm-setup, which is where --gm-t-choice is declared and the
 * only place this card is ever drawn. A bare .gm-setup-confirm-card selector
 * would read a token that happened to be in scope, which is exactly the
 * fragility item 10 of the same review named on .gm-switch-state.
 *
 * NOTHING GROWS BY IT. The answers are min-height 48 around a 21px line box,
 * so four pixels of type is spent out of the padding rather than out of the
 * card, and the card is position:fixed either way. */
.gm-setup .gm-setup-confirm-card .gm-room-confirm-actions button {
  font-size: var(--gm-t-choice);
}

/* ============ THE BREAK LENGTH, IN FIVE MINUTE STEPS ============
   A stepper rather than a field. A musician setting a break knows it as "about
   fifteen minutes" and typing a clock time on a stage is the slower of the two;
   the clock field underneath is for the case where somebody has been told when
   the next act is on. */
.gm-room-stepper {
  display: flex;
  align-items: center;
  gap: 10px;
}
.gm-step {
  flex: none;
  width: 48px;
  height: 48px;
  display: grid;
  place-items: center;
  border: 1px solid var(--border-strong, var(--border));
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--text-primary);
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
}
.gm-step:hover { border-color: var(--action); }
.gm-step:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }
.gm-step-value {
  flex: 1 1 auto;
  text-align: center;
  font-size: 1.15rem;
  font-weight: var(--weight-strong);
  color: var(--text-primary);
  /* Tabular, so 15 and 20 do not move the two buttons under the thumbs. */
  font-variant-numeric: tabular-nums;
}
.gm-room-time {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: 10px;
}
.gm-room-or {
  flex: none;
  font-size: .85rem;
  color: var(--text-secondary);
}
.gm-room-timefield { flex: 1 1 auto; }
/* The app's own switch, with the panel's spacing rather than the dashboard
   form's, and the stage screen's hit target rather than the desk's. The track
   itself is untouched: this widens what a thumb can land on, it does not
   introduce a different control. */
.gm-room-toggle {
  margin: 0;
  min-height: 44px;
}

/* ============ THE STATUS STRIP ============
 *
 * Between the bar and the scroller, in normal flow. It cannot be scrolled away
 * from because it is not in the scroller, and it cannot cover a card or a
 * control because it takes its own height rather than floating over them.
 *
 * IT NAMES EACH STATE IN WORDS. A reviewer diffed the six states and found a
 * message showing and requests switched off producing byte-identical screens,
 * and the paused state disappearing entirely once a break started. Colour and
 * position are not the signal here: each chip carries its own word, and the
 * two that have a mark as well have a SHAPE, a round dot for a break and a
 * bar for requests off, because this screen is read in a room lit for the
 * audience and often under a coloured wash.
 *
 * AND IT IS THE ROUTE BACK IN. The two controls above the queue scroll away in
 * a long queue, so whenever there is a state to change this is a control to
 * change it with. */
.gm-statusstrip {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
  width: 100%;
  /* THE SAME COLUMN THE CARDS USE. It ran the full width while .gm-queue is
     capped at 900 and centred, so on a laptop the strip and the two controls
     above the queue overhung the list by 250px on each side and shared no edge
     with it. */
  max-width: 900px;
  margin: 0 auto 10px;
  padding: 8px;
  border: 1px solid var(--border-strong, var(--border));
  border-radius: var(--radius);
  background: var(--surface-raised, var(--surface));
}
/* The chips are the target, so the whole reading area opens the panel. */
.gm-statusstrip-open {
  flex: 1 1 auto;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 6px;
  min-height: 44px;
  min-width: 0;
  padding: 0 4px;
  background: transparent;
  border: 0;
  text-align: left;
  cursor: pointer;
}
.gm-statusstrip-open:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }
/* ============ FILLED, NOT OUTLINED ============
 * Every chip was a 1px outline with a 9px mark in it. A reviewer put it
 * plainly: nothing on this screen survives a coloured stage light washing
 * across it, and the mark distinguishing a message from a break was two
 * purples at nine pixels differing by corner radius. A tinted ground is a
 * shape the size of the whole chip, and it is the same treatment .gm-mine
 * already uses on a card a few hundred lines down. */
.gm-chip {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  max-width: 100%;
  min-height: 34px;
  padding: 4px 12px;
  border: 1px solid transparent;
  border-radius: 999px;
  background: var(--surface-sunken, var(--surface));
  color: var(--text-primary);
  font-size: .95rem;
  font-weight: var(--weight-medium, 500);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.gm-chip.is-break {
  background: var(--action-quiet, var(--surface-raised));
  border-color: var(--action);
}
/* THE ONE YOU LAND ON AFTER ENDING A BREAK. Going from break-and-paused to
   paused, this chip is all that is left, and it was a 1px outline at the end
   of a line nobody is still reading. It gets the strongest treatment of the
   three because it is the state with the worst consequence: requests silently
   off for the rest of the set. */
.gm-chip.is-paused {
  background: var(--negative-quiet, var(--surface-raised));
  border-color: var(--negative, var(--border-strong));
  color: var(--negative-ink, var(--text-primary));
}
/* ============ THE MESSAGE CHIP IS A STATE, NOT AN ECHO ============
 * It had no mark, no word and the dimmest text on the strip: a quoted string
 * on its own reads as a field showing what you typed rather than as something
 * the room is looking at. It is the only one of the three the audience can
 * actually see, and it was the one the musician could not. It now carries the
 * same accent as the other two, its own SHAPE (a square, against the break's
 * circle and the pause's bar) and the word Showing before the message. */
.gm-chip.is-said {
  background: var(--surface-raised, var(--surface));
  border-color: var(--action-on-surface, var(--action));
  /* The one chip that can be long. It shrinks rather than pushing the break's
     countdown and the controls off the row. */
  flex: 0 1 auto;
  min-width: 0;
}
.gm-chip-what {
  flex: none;
  font-weight: var(--weight-strong);
}
.gm-chip-said {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--text-secondary);
}
/* A SHAPE EACH, NOT TWO COLOURS. Round for a break, a bar for requests off. */
.gm-chip-dot {
  width: 9px; height: 9px;
  border-radius: 50%;
  background: var(--action);
}
.gm-chip-bar {
  width: 11px; height: 3px;
  border-radius: 2px;
  background: var(--negative, var(--action));
}
.gm-chip-square {
  width: 9px; height: 9px;
  border-radius: 2px;
  background: var(--action-on-surface, var(--action));
}
/* ============ THE COUNTDOWN IS THE NUMBER THIS STRIP EXISTS FOR ============
   It was 11px and grey, against the gig's own "2h 59m" at 22px, white and
   bold: the figure nobody needs between songs was rendered twice the size of
   the one the strip is for, and at arm's length on a mic stand 11px grey does
   not resolve at all. */
/* ============ THE NUMBER THE STRIP EXISTS FOR ============
 * It has been measured twice and been too small twice: 12px of cap height at
 * first, then 16px, against 15px song titles behind it and 17px for the gig's
 * own remaining time in the header. Being one pixel taller than a song title
 * is not being the biggest thing in a strip that exists to show it.
 * It is out of the chip now and between the two step buttons, where there is
 * room for it to be what it is. */
.gm-chip-clock {
  flex: none;
  padding: 0 4px;
  font-size: 1.75rem;
  font-weight: var(--weight-strong);
  color: var(--text-primary);
  font-variant-numeric: tabular-nums;
  line-height: 1.05;
  /* A minimum, so the two buttons either side do not move under a thumb as the
     seconds tick from 10:00 to 9:59. Wide enough for "10:00 left" and no
     wider: every character beyond that is width taken off the row. */
  min-width: 6.6ch;
  white-space: nowrap;
  text-align: center;
}
.gm-statusstrip.is-over .gm-chip-clock { color: var(--negative, var(--text-primary)); }
/* PAST THE TIME THEY SET. Nothing ends on its own: the strip says the break has
   run over and keeps counting, because an app deciding somebody is back and
   telling a room so would be wrong every time a break overran. */
.gm-statusstrip.is-over .gm-chip.is-break { border-color: var(--negative, var(--action)); }
.gm-statusstrip.is-over .gm-chip-dot { background: var(--negative, var(--action)); }

/* ============ THE CONTROLS SIT WITH THE THING THEY CHANGE ============
 * margin-left: auto pushed them to the far right, which on a laptop left 470px
 * of nothing between the countdown and the two buttons that move it: they read
 * as two unrelated things sharing a row. And on a phone it put the whole
 * cluster in the top-right corner, which is the worst reach on the screen for
 * either hand. They sit beside the chips now, and when the row wraps on a
 * phone they take the full width of the second row so both thumbs can reach
 * them. */
.gm-statusstrip-controls {
  display: flex;
  align-items: center;
  /* IT WRAPS. At 390 the stepper, its number and the way back come to about
     ten pixels more than the strip has, and without this the last control was
     squeezed until "I am back" broke over two lines and lost its right-hand
     edge off the strip. The stepper stays together on the first line because
     nothing in it may wrap between the buttons and the value they change. */
  flex-wrap: wrap;
  gap: 10px;
}
.gm-step-small {
  width: auto;
  min-width: 62px;
  height: 50px;
  padding: 0 12px;
  font-size: 1.05rem;
  font-weight: var(--weight-strong);
}
/* ============ AND THE WAY BACK IS NOT NEXT TO THE STEPS ============
   It sat seven pixels from the plus and is the only filled control in the
   strip, so it is where the eye and the thumb go: reaching for five more
   minutes in the dark ended the break instead. Ending a break has to stay ONE
   tap from here, so the answer is distance rather than a confirmation. */
/* ============ AND THE WAY BACK IS A BUTTON ============
 * Round two took the fill off it, so it should not be the loudest thing in the
 * strip, and left it with a single left-hand rule: a reviewer scanned straight
 * through it and found strip background for all 46 rows, no top, bottom or
 * right edge and no fill. The control that ends a break and puts a room back
 * had less chrome than the two step buttons beside it, and no visible tap
 * boundary at all.
 * Outlined, which is a button, and still not filled, which keeps it quieter
 * than the pair it must not be confused with. The gap before it stays. */
.gm-statusstrip-end {
  min-height: 50px;
  margin-left: 16px;
  white-space: nowrap;
  padding: 0 16px;
  background: transparent;
  border: 1px solid var(--border-strong, var(--border));
  border-radius: var(--radius);
  color: var(--text-primary);
  font-size: 1rem;
  font-weight: var(--weight-strong);
  cursor: pointer;
}
.gm-statusstrip-end:hover {
  border-color: var(--action);
  color: var(--action-on-surface, var(--action));
}
.gm-statusstrip-end:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }

.gm-addpanel {
  /* THE ANCHOR THE FLOATING RESULTS HANG FROM. */
  position: relative;
  margin: 0 0 12px;
  padding: 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface-sunken, var(--surface));
}
/* ============ THE WAY OUT WITHOUT ADDING ============
 * In the corner, where every other panel on this screen puts it, rather than
 * as a second button at the foot beside the one that writes. 44px, which is
 * what everything else on this screen is held to, and it overlaps the panel's
 * own padding so it does not push the first label down. */
.gm-add-x {
  position: absolute;
  top: 4px;
  right: 4px;
  z-index: 3;
  width: 44px;
  height: 44px;
  display: grid;
  place-items: center;
  background: transparent;
  border: 0;
  border-radius: var(--radius);
  color: var(--text-secondary);
  font-size: 17px;
  line-height: 1;
  cursor: pointer;
}
.gm-add-x:hover { color: var(--text-primary); }
.gm-add-x:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }
/* The song label must not run under it. The note label is in the second column
   from 520px up and never reaches that corner. */
.gm-add-fields > .gm-add-label:first-child { padding-right: 44px; }
/* THE TWO FIELDS. Stacked on a phone; the note beside the song from 520px up,
   where there is room for both without either becoming a slot. The note is
   narrower because a name is short and the song is what is being searched. */
.gm-add-fields { display: grid; gap: 6px 10px; }
@media (min-width: 520px) {
  .gm-add-fields {
    grid-template-columns: minmax(0, 1.9fr) minmax(0, 1fr);
    align-items: end;
  }
  .gm-add-label { grid-row: 1; }
  .gm-add-field { grid-row: 2; }
}
.gm-add-label { display: block; margin-bottom: 0; font-size: .9rem; color: var(--text-secondary); }
/* Quieter, because it is the optional one and the pair should not read as two
   equal questions. */
.gm-add-label-note { color: var(--text-muted, var(--text-secondary)); }
.gm-add-field {
  width: 100%;
  min-height: 52px;
  padding: 0 12px;
  border: 1px solid var(--border-strong, var(--border));
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--text-primary);
  font-size: 1rem;
}
.gm-add-field-note { border-style: dashed; }
/* ============ THE RESULTS FLOAT OVER THE PANEL ============
 *
 * They were laid out inside it, so the panel grew by three rows the moment
 * anybody typed and the queue underneath was pushed down the screen while the
 * musician was still deciding. Anchored under the fields instead, so nothing
 * below the panel moves at all while a search is open.
 *
 * THE AUDIENCE'S OWN LIST IS DELIBERATELY IN FLOW, and the comment in
 * css/public.css says why: as an absolutely positioned dropdown it covered the
 * Send button, so a tap meant for Send landed on a suggestion and silently
 * replaced the punter's song. That fault cannot happen here, and the reason is
 * a property of this panel rather than of its geometry: THE CONTROL THAT
 * WRITES IS DISABLED FOR AS LONG AS THIS LIST IS OPEN. It unlocks only once
 * something has been picked, picking closes the list, and typing again (the
 * only way to reopen it) unpicks and locks it back. So there is never a
 * moment when a musician wants to hit Queue song and this is over it. The
 * audience's Send has no such invariant: it is live from the first keystroke,
 * because free text is a valid request there.
 *
 * tests/added-from-the-stage.mjs asserts the invariant rather than trusting
 * this paragraph, because the paragraph is only true while the handler that
 * unpicks on input keeps existing.
 *
 * Empty it collapses to nothing and takes its border with it, which is what
 * :empty is for: an overlay with a 1px outline and no rows in it is a hairline
 * floating under the field on every screen where nobody has typed. */
.gm-add-rows {
  position: absolute;
  /* Above the foot, so every row in it can be tapped. */
  z-index: 5;
  left: 12px;
  right: 12px;
  display: grid;
  gap: 6px;
  max-height: 44dvh;
  padding: 6px;
  overflow-y: auto;
  overscroll-behavior: contain;
  background: var(--surface-raised, var(--surface));
  border: 1px solid var(--border-strong, var(--border));
  border-radius: var(--radius);
  box-shadow: 0 12px 30px rgb(0 0 0 / .35);
}
.gm-add-rows:empty {
  padding: 0;
  border: 0;
  box-shadow: none;
}
.gm-add-row {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  width: 100%;
  min-height: 52px;
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--text-primary);
  text-align: left;
  cursor: pointer;
}
.gm-add-row:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: 2px; }
.gm-add-title { font-size: 1rem; }
.gm-add-by { font-size: .85rem; color: var(--text-secondary); }
.gm-add-note { margin: 8px 0 0; font-size: .85rem; line-height: 1.35; color: var(--text-secondary); }
/* ============ THE TWO CONTROLS AT THE FOOT OF THE PANEL ============
   Done writes; Close does not. Done leads and takes the room, because a
   musician who has just picked a song is reaching for it. */
/* One control, and it is the one that writes. */
.gm-add-go {
  display: flex;
  gap: 10px;
  margin-top: 10px;
}
.gm-add-go button { flex: 1 1 auto; min-height: 46px; }
.gm-add-done:disabled { opacity: .5; cursor: default; }

/* THE ROW THAT WAS PICKED. The list now closes on a pick, the same as the
   audience's, so this is only ever seen for the instant before it goes; it is
   kept because the list is repainted from the same markup and a picked row
   that looked like every other one would flash as an unmarked list. */
.gm-add-row.is-picked {
  border-color: var(--action);
  box-shadow: inset 0 0 0 1px var(--action);
}

/* ============ ONE MENU ON A DESKTOP ============
 *
 * A desktop had TWO: four tabs down the left and a burger at the top carrying
 * six more things, one of which was Settings. Nothing said which menu held
 * what, so the answer to "where is Log out" was "try both".
 *
 * On a phone that is right: the bottom bar is thumb-reach and a drawer is where
 * everything else goes. On a screen with nine hundred pixels of unused height
 * down the left it is two menus because nobody merged them.
 *
 * THE BREAKPOINT IS THE PANEL'S OWN. 1100px wide, landscape, at least 821px of
 * height: exactly the three conditions the side panel already appears under,
 * because a second breakpoint would eventually disagree with the first and
 * produce a window with a sidebar and a burger in it.
 *
 * What that includes and excludes, measured rather than assumed. A laptop and
 * an 11-inch iPad in landscape (1194 by 834) are above it and get the one
 * panel. A tablet on a mic stand in landscape is below the 821px height, keeps
 * the left rail and keeps the drawer, which is right: that is gig work, and the
 * hand comes in from the edge. A tablet in portrait and every phone keep the
 * bottom bar and the drawer, which is what a thumb wants.
 *
 * Both groups are rendered at every width and one of them is hidden, so the two
 * arrangements cannot disagree about what exists. */
.panel-foot { display: none; }

@media (min-width: 1100px) and (orientation: landscape) and (min-height: 821px),
       (min-width: 1100px) and (orientation: landscape) and (pointer: fine) {
  /* THE BURGER GOES. There is nothing left behind it. */
  .shell-menu-button { display: none; }

  /* ============ AND THE FOOT IS AT THE FOOT ============
     `margin-top: auto` on a grid item does nothing when the rows are sized to
     their content, which is what grid-auto-rows: max-content above does: a
     reviewer measured Log out ending at y=578 with 490 pixels of empty rail
     under it. A flex column is what makes auto margins mean "take the rest",
     and the panel is a single column of items, which is what a flex column is
     for. */
  .shell-nav {
    display: flex;
    flex-direction: column;
    /* STRETCH, EXPLICITLY. The bottom-bar rule this inherits from sets
       `align-items: end`, which in a column means the cross axis, so every item
       shrank to its content and sat against one edge: the routes ended up
       indented forty pixels further than the errands and the whole panel had a
       ragged left edge down the middle of it. */
    align-items: stretch;
    gap: 4px;
  }
  .shell-nav .nav-item, .shell-nav .nav-golive { flex: none; }
  /* ONE AXIS. The circle was centred in the rail while every item under it
     ranged left, which is an axis break at the top of the panel and the first
     thing the eye lands on.
     IT IS NOT A CIRCLE ANY MORE. A full-width button has no axis to break: it
     starts and ends where every row under it does, which is what the rule was
     reaching for. Stretched rather than ranged left, and the padding matches
     the block above rather than being set on one side only. */
  .nav-golive { align-items: stretch; padding: 0; }
  .panel-foot {
    display: grid;
    gap: 2px;
    /* WHATEVER IS LEFT. On a short window this collapses to nothing and the
       foot sits directly under the routes rather than being pushed off the
       bottom. */
    margin-top: auto;
    padding-top: 12px;
    /* A RULE YOU CAN SEE. It was a hairline in --border, which a reviewer
       measured at 53 against a 29 ground on the app's own dark theme: a
       separator nobody can see is not a separator, and it was the only thing
       saying the two groups were two groups. */
    border-top: 1px solid var(--border-strong, var(--border));
  }
  /* ============ SMALLER TYPE, BECAUSE THESE ARE ERRANDS ============
     Not decoration: About, Contact and Log out are things somebody does once a
     month, and setting them at the size of the routes would put five items of
     equal weight under four that are the reason the app is open. */
  /* ============ THE ROUTES ARE LOUDER, AND MEASURABLY SO ============
     The first attempt set the errands at 13px against the routes' 14 and left
     the colour alone. A reviewer sampled both and found the same colour and the
     same ten-pixel cap height: ten items of equal weight in one stack, which is
     the fault the consolidation was supposed to remove. The routes go up and
     the errands come down, so the difference is three points and a tone rather
     than one pixel of nothing. */
  .shell-nav .nav-item { font-size: 15px; color: var(--text-primary); }
  .panel-foot-item {
    display: flex;
    align-items: center;
    /* ============ ONE TEXT COLUMN ============
       The icons were 23px in the routes and 17px here with a different gap, so
       the labels started at x=62 above the rule and x=53 below it: a ragged
       left edge down the middle of the panel, which is exactly what still made
       it read as two menus sharing a column. The icon BOX is the same in both
       and only the glyph inside it is smaller. */
    gap: 12px;
    width: 100%;
    min-height: 38px;
    padding: 0 14px;
    border: 0;
    border-radius: 9px;
    background: transparent;
    color: var(--text-muted, var(--text-secondary));
    font-size: 12px;
    text-align: left;
    text-decoration: none;
    cursor: pointer;
  }
  .panel-foot-item:hover { background: var(--surface-raised); color: var(--text-primary); }
  .panel-foot-item:focus-visible { outline: 3px solid var(--focus-ring); outline-offset: -2px; }
  .panel-foot-item.active { color: var(--text-primary); background: var(--surface-raised); }
  /* THE ADMIN ENTRY, AMBER HERE TOO. See the note beside .drawer-item--admin:
     this has to sit inside this media query rather than above it, because
     .panel-foot-item's own colour is declared in here and would otherwise win
     on source order. */
  .panel-foot-item--admin {
    color: var(--notice-text);
    box-shadow: inset 3px 0 0 0 var(--notice);
  }
  .panel-foot-item--admin svg { fill: var(--notice); }
  .panel-foot-item--admin:hover { background: var(--notice-surface); color: var(--notice-text); }
  /* THE SAME BOX THE ROUTES USE, so the labels line up, with a smaller glyph
     inside it. Filled like the routes' icons rather than stroked: a set of four
     solid marks above a set of five outlined ones is two icon sets. */
  .panel-foot-item svg {
    width: 23px;
    height: 23px;
    padding: 3px;
    box-sizing: border-box;
    flex: none;
    fill: currentColor;
  }
  .panel-foot-label { display: inline-flex; align-items: baseline; gap: 5px; }
  /* ============ THE ARROW THAT MEANS "THIS LEAVES" ============
     It was nine pixels of low-contrast grey floated thirty pixels to the right
     of its own label, in a column nothing else used: a reviewer called it
     exactly the character nobody notices, and measured it at 3.4 to 1. It sits
     against the label now, at the label's own colour, and larger. */
  .panel-foot-out { font-size: 13px; line-height: 1; }
}


/* ==========================================================================
   THE LIVE SCREEN ON A PHONE
   ==========================================================================
 *
 * ALL OF IT IN ONE PLACE, AND THAT PLACE IS THE END OF THE FILE. Two earlier
 * attempts at this were written next to the rules they were overriding, half
 * way up, and lost to base rules further down: media queries add no
 * specificity, so a later `.gm-addsong { min-height: 52px }` beats an earlier
 * `@media (max-width: 430px) .gm-addsong { min-height: 38px }` every time. The
 * buttons did not move and it was not obvious why. Anything that has to win
 * against the whole of this stylesheet goes after the whole of this stylesheet.
 *
 * WHAT THIS IS FOR. Mark, 3 September 2026, on a 375px screen: the buttons are
 * too big, the header is too big, the text is too big, the title truncates, and
 * there are massive gaps between the sections above the queue. Every one of
 * those is the same fault. This screen was sized from one sound premise, that
 * it is read at half a metre off a stand, and that premise was then applied to
 * every string, every control and every gap without asking what a phone is.
 *
 * NOTHING HERE TOUCHES A TAP TARGET'S REACH. Played and Skip keep their 44px
 * through the ::after that lets the box be smaller than the target. The two
 * controls above the queue are full width of their half of the row, so the only
 * way to miss one is vertically, and 38px across 160px is not a small target.
 * ========================================================================== */
@media (max-width: 430px) {
  /* ---- the bar: LIVE, the clock, the countdown ---- */
  .gm-bar {
    gap: 2px 12px;
    padding-bottom: 6px;
    margin-bottom: 6px;
  }
  .gm-bar-status { gap: 8px; }
  .gm-bar-clock { gap: 10px; font-size: var(--gm-t-small); }
  .gm-clock { padding: 1px 8px 1px 6px; font-size: var(--gm-t-small); }
  .gm-clock-face { width: 12px; height: 12px; }
  /* THE COUNTDOWN IS THE TALLEST THING IN THE BAR, at 54px of the 73 the bar
     measured: 21px of type with 11px of padding above and below it. The padding
     is a tap target (it opens the end-time panel) and it comes down to 7, which
     with the type at --gm-t-large is still 31px in a corner of the screen with
     nothing beside it to mis-hit. `button.gm-remaining` because that is the
     selector the padding is set with, and a plain .gm-remaining loses to it. */
  button.gm-remaining { padding: 7px 6px; }
  .gm-remaining { font-size: var(--gm-t-large); padding-left: 10px; }
  .gm-remaining-word { font-size: 9px; }

  /* ---- the three figures ----
     THE GAPS WERE THE PROBLEM, NOT THE TYPE. This row had most of a --gm-space
     unit above it and a whole one below, plus a rule under that, which is three
     separate pieces of air around one line of text. nowrap because the wording
     is short enough now to promise it. */
  .gm-counts {
    flex-wrap: nowrap;
    gap: 0 14px;
    padding: 5px 0 6px;
    font-size: var(--gm-t-small);
    line-height: 1.3;
  }
  .gm-stat { white-space: nowrap; }
  .gm-stat b { font-size: var(--gm-t-mid); }
  /* THE TIP TOTAL IS A BUTTON AND IT WAS HOLDING THE ROW OPEN AT 44px, which
     is 44 of the 56 this row measured. Same answer as Played and Skip: the box
     comes down and the reach does not. 26 drawn plus 9 top and bottom is the
     44 it had, and the row it sits in absorbs the overhang because there is
     nothing else in it to hit. */
  .gm-stat-tips { position: relative; min-height: 26px; }
  .gm-stat-tips::after { content: ""; position: absolute; inset: -9px -6px; }

  /* THE ✕ SETS THE HEIGHT OF THE TOP ROW and reserves its own width beside the
     clock underneath. 36 is still a comfortable target for a control that has
     the corner of the screen to itself, and it takes 8px off both rows. */
  .gm { --gm-exit: calc(36px * var(--density)); }

  /* The queue's own top padding, and the space the two controls sit in. */
  .gm-scroll { padding-top: 4px; }

  /* ---- the two controls above the queue ---- */
  .gm-addsong {
    min-height: 38px;
    margin-bottom: 0;
    font-size: var(--gm-t-small);
  }
  .gm-stagerow { gap: 8px; margin: 4px 0 8px; }
  .gm-stagerow .gm-addsong { margin-bottom: 0; }

  /* ============ AND THE ACT GOES UNDER THE SONG ============
   *
   * WHERE MARK ACTUALLY MEANT, and two earlier passes put it somewhere else.
   * Not on the request row: that row is who asked, and taking width from it
   * cost a name. The gap is the one INSIDE the title's own row. That row is
   * 36px tall because Played and Skip hold it open, and the song title fills
   * about 20 of them, so there are roughly 16px of empty space sitting directly
   * under the title with nothing in it.
   *
   * So the title column becomes two stacked lines and spends space the card was
   * already paying for. The card gets no taller, the title keeps the whole
   * width of its line rather than sharing it, and the act is on the card.
   *
   * The act takes the smallest step on the ramp and a tight line height,
   * because the two of them together have to fit inside what the buttons hold
   * open or the row grows and the whole point is lost. */
  /* THE ROW'S OWN LINE BOX, WHICH NOTHING IS IN. .gm-line is a flex row whose
     children all set their own line-height, and it was still carrying an
     inherited one of its own: 40px tall around a 36px button and a 34px stack.
     Four pixels a card, on a screen showing five of them. */
  .gm-line { line-height: 1; }
  .gm-title {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    justify-content: center;
    min-width: 0;
    max-width: 100%;
    line-height: 1.2;
  }
  .gm-song { max-width: 100%; }
  .gm-artist.is-inline {
    display: block;
    max-width: 100%;
    padding-left: 0;
    /* 12, NOT THE 10 THIS STARTED AT. The constraint is the row the buttons
       hold open, 36px, and the two lines have to live inside it or the card
       grows and the whole point of putting the act here is lost. A 16px title
       at 1.2 is 19px and a 12px act at 1.15 is 14, which is 33 of the 36. Ten
       was the safe guess before that was measured; twelve is what actually
       fits. Mark's call, 4 September 2026. */
    font-size: calc(12px * var(--font-scale));
    line-height: 1.15;
  }
  /* AND NOT ON THE REQUEST ROW. Rendered and never drawn: see the note beside
     .gm-artist.is-row2 further up for why the copy is kept. */
  .gm-artist.is-row2 { display: none; }
}

/* ================= REPORTING A REQUEST, INSIDE THE PEEK PANEL ==============
 *
 * Quiet until it is asked for. The button is not styled as a danger control: a
 * musician reporting something is already having a bad moment and a red button
 * shouting at them does not help. It is the reason box, not the button, that
 * carries the weight.
 *
 * The whole thing sits above Close, so the panel still ends with the way out. */
.gm-peek-report {
  display: block;
  width: 100%;
  margin: calc(12px * var(--density)) 0 0;
  padding: 12px;
  min-height: 44px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: transparent;
  color: var(--text-secondary);
  font: inherit;
  cursor: pointer;
}
.gm-peek-report:hover { background: var(--surface-raised); }

.gm-peek-reportbox {
  margin: calc(12px * var(--density)) 0 0;
  padding: 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  text-align: left;
}

.gm-peek-reportwhy {
  margin: 0 0 10px;
  font-size: var(--step-2);
  line-height: 1.45;
}

.gm-peek-reasonlabel {
  display: block;
  margin: 0 0 6px;
  font-size: var(--step-2);
}

/* 16px, because anything smaller makes iOS zoom the page when it is focused,
   and a panel that jumps as somebody starts typing is a panel they leave. */
.gm-peek-reason {
  width: 100%;
  min-height: 44px;
  padding: 10px 12px;
  font-size: 16px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  color: var(--text-primary);
}

/* WHICH ONE, when more than one person asked for the song. One row per person,
   each a whole 44px target, because this is tapped on a phone at a gig and the
   radio dot on its own is a far smaller thing to hit. */
.gm-peek-pick {
  margin: 0 0 10px;
  padding: 0;
  border: 0;
  min-width: 0;
}
.gm-peek-pickrow {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 44px;
  padding: 4px 8px;
  border-radius: var(--radius);
  cursor: pointer;
}
.gm-peek-pickrow:hover { background: var(--surface-raised); }
.gm-peek-pickrow input { width: 20px; height: 20px; margin: 0; flex: none; }
.gm-peek-pickname { overflow-wrap: anywhere; }
.gm-peek-reportsend:disabled { opacity: 0.5; cursor: default; }

.gm-peek-reporterror {
  margin: 8px 0 0;
  font-size: var(--step-2);
  color: var(--negative-text);
}

.gm-peek-reportrow {
  display: flex;
  gap: 8px;
  margin: 10px 0 0;
}
.gm-peek-reportrow button {
  flex: 1 1 auto;
  min-height: 44px;
  padding: 10px 12px;
  border-radius: var(--radius);
  font: inherit;
  cursor: pointer;
}
.gm-peek-reportsend {
  border: 0;
  background: var(--action);
  color: var(--action-text);
}
.gm-peek-reportcancel {
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-primary);
}

.gm-peek-reported {
  margin: calc(12px * var(--density)) 0 0;
  font-size: var(--step-2);
  color: var(--positive-text);
}

/* ============ ONE CLEAR ANSWER, OFFERED AND NOT TAKEN ============
 *
 * When the reading is good enough to name one building, the venue goes in the
 * box and this asks about it. It stands where the nearby buttons would, and it
 * replaces them, because it IS the nearby list with the doubt taken out: the
 * rest are one tap away behind "Somewhere else".
 *
 * IT COSTS THE FORM NO MORE THAN THE BUTTONS DID, and usually less: two
 * buttons on one row rather than four on two. */
.gm-confirm { margin: 8px 0 10px; }
.gm-confirm-ask {
  margin: 0 0 8px;
  font-size: var(--gm-t-small);
  font-weight: var(--weight-medium);
  color: var(--text-primary);
}
.gm-confirm-row { display: flex; gap: 8px; flex-wrap: wrap; }
.gm-confirm-row button { flex: 1 1 130px; min-height: 44px; }

/* GO LIVE, HELD. Not hidden and not moved: a musician whose phone guessed the
   wrong pub would find a missing button far worse than a dimmed one with a
   sentence beside it. The sentence is in the hint line, which is reserved. */
.gm-primary.is-held { opacity: .55; }
.gm-confirm-why {
  margin: 8px 0 0;
  font-size: var(--gm-t-small);
  color: var(--text-secondary);
}

/* ============ WHAT ELSE THIS PERSON ASKED FOR ============
 *
 * Mark, 28 September 2026. A dialog over everything, opened by tapping an
 * identifier and dismissed by its own Close or its own scrim, so it adds no
 * row and no control to the live screen.
 *
 * OVER THE PEEK, because it can be opened from inside it. The ladder in
 * css/shared.css already names this: a dialogue launched from a modal sits
 * above that modal. */
.gm-person-overlay { z-index: var(--layer-dialog); }
.gm-person { max-width: 26rem; }
.gm-person-who {
  margin: 0 0 2px;
  font-size: var(--step-2);
  font-weight: 600;
  display: flex;
  align-items: baseline;
  gap: 6px;
  flex-wrap: wrap;
}
.gm-person-say {
  margin: 0 0 12px;
  font-size: var(--step-1);
  color: var(--text-secondary);
}
/* What this person gave at this gig, on the summary line rather than a row of
   its own. The amount is the one thing on the line worth finding at a glance. */
.gm-person-given { white-space: nowrap; }
.gm-person-given b {
  color: var(--action-on-surface);
  font-weight: var(--weight-strong);
}
.gm-person-list { display: grid; }
.gm-person-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  padding: 9px 0;
  border-top: 1px solid var(--border);
}
.gm-person-row:first-child { border-top: 0; }
.gm-person-song { display: grid; gap: 1px; min-width: 0; }
.gm-person-title { overflow-wrap: anywhere; }
.gm-person-by { font-size: var(--step-1); color: var(--text-secondary); overflow-wrap: anywhere; }
/* THE STATE IS A WORD AND NOT ONLY A COLOUR, because this is read at arm's
   length and because a colour alone is not a label for anybody who cannot
   separate two of them. */
.gm-person-state { flex: none; font-size: var(--step-1); white-space: nowrap; }
.gm-person-state.is-waiting { color: var(--text-primary); font-weight: 600; }
.gm-person-state.is-played { color: var(--positive-text, #1a7f4b); }
.gm-person-state.is-skipped { color: var(--text-secondary); }
.gm-person-at { color: var(--text-secondary); font-weight: 400; }

/* The identifiers in the played and skipped history. A third line in the
   song's own column, the same place the artist already sits, so the row keeps
   its shape and the sheet gains no column. */
.gm-sheet-who {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin-top: 3px;
}

/* ============ THE HANDOVER BETWEEN ACTS ============
 *
 * All of it lives inside the slide-up sheet, which is one of the three places
 * the live screen's standing rule allows a new affordance, and the only thing
 * it puts on the main screen is one more word on a bar that was already there.
 * No new row, no new height.
 */
.gm-handle-part.is-waiting {
  color: var(--action-on-surface, var(--action));
  font-weight: 600;
}
/* THE HANDOVER TAB FILLS THE PANEL: its list scrolls, its button does not.
   .gm-sheet-inner is a column of fixed height; this is the part under the
   tabs, and the footer below the scroller is always in sight. */
.gm-before { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.gm-before-scroll { display: grid; align-content: start; gap: 10px; }
.gm-before-say { margin: 0; font-size: var(--gm-t-body); color: var(--text-secondary); }
.gm-before-list { display: grid; }
/* A whole row is the target, not the box: this is ticked mid-gig, one handed,
   with a guitar on. */
.gm-before-row {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 10px 2px;
  border-top: 1px solid var(--border);
  min-height: 44px;
  cursor: pointer;
}
.gm-before-row:first-child { border-top: 0; }
.gm-before-row input { width: 22px; height: 22px; flex: none; margin-top: 2px; }
.gm-before-song { display: grid; gap: 1px; min-width: 0; }
.gm-before-title { overflow-wrap: anywhere; }
.gm-before-for { font-size: var(--gm-t-small); color: var(--text-secondary); overflow-wrap: anywhere; }
.gm-before-actions {
  flex: none;
  display: flex;
  justify-content: flex-end;
  padding-top: 10px;
  border-top: 1px solid var(--border);
  background: var(--surface-raised);
}
.gm-before-add {
  min-height: 44px;
  padding: 0 16px;
  border-radius: var(--radius);
  border: 0;
  background: var(--action);
  color: var(--action-ink, #fff);
  font: inherit;
  font-weight: 600;
}
.gm-before-add:disabled { background: var(--surface-raised); color: var(--text-secondary); }

/* WHAT THE ACT BEFORE PLAYED. Collapsed from the start, with its count on the
   summary, because it is a fact to consult rather than a thing to decide
   about. */
.gm-before-played { border-top: 1px solid var(--border); padding-top: 8px; }
.gm-before-played > summary { cursor: pointer; font-weight: 600; }
.gm-before-playedlist { display: grid; padding-top: 6px; }
.gm-before-playedrow {
  display: flex;
  justify-content: space-between;
  gap: 10px;
  padding: 7px 0;
  border-top: 1px solid var(--border);
}

/* ============ PLAYED HERE EARLIER, AND IT HAS TO BE SEEN ============
 *
 * Mark, 28 September 2026: it was too quiet. When a song is requested that the
 * act before already played, that is exactly the thing to notice before
 * deciding whether to play it again, and a grey outline on a screen read at
 * arm's length in a dark room is not it.
 *
 * AMBER, NOT RED, AND THAT IS A CHOICE AGAINST MARK'S FIRST SUGGESTION.
 * Red means a fault everywhere else in this product: the location dot's error
 * state, "that did not load", the destructive controls in admin. This marker
 * is not a fault. Nothing is wrong with the request, nothing failed, and
 * nobody needs to fix anything: it is a fact about what happened an hour ago.
 * A red one would read as a warning about the request itself and would make a
 * musician look for the problem. --notice is the amber this app already uses
 * for "look at this, it is not an error", which is precisely what this is, and
 * the drawer's admin entry is styled from the same token for the same reason.
 *
 * THE TOKENS, NOT NEW COLOURS. --notice-surface, --notice and --notice-text
 * are defined by every one of the four app themes, so it holds up in all of
 * them by construction rather than by being checked in two.
 *
 * AND IT TAKES NO MORE ROOM. Same row, same padding, same one line: a filled
 * plane and a border instead of an outline, which is a step in brightness
 * before it is a colour. The live screen gains no height. */
.gm-before-mark {
  flex: none;
  padding: 1px 7px;
  border-radius: 999px;
  border: 1px solid var(--notice);
  background: var(--notice-surface);
  color: var(--notice-text);
  font-weight: 600;
  font-size: var(--gm-t-micro, var(--gm-t-small));
  white-space: nowrap;
}

/* ============ THE HANDOVER SCREEN, BETWEEN GO LIVE AND THE QUEUE ==========
 *
 * A full screen rather than a panel, because that is what it is: the moment
 * between pressing Go Live and the queue appearing. Mark's reasoning is that
 * the musician is stood ready then and a screen taking over the space is fine,
 * whereas mid-gig it would be in the way.
 *
 * THE TWO WAYS OUT ARE PINNED and the lists scroll behind them, so neither can
 * be scrolled away from on a long list. "Not now" is first and is reachable by
 * one tap wherever the list has got to, which is rule (b): Mark will sometimes
 * press Go Live with the landlord waiting. */
.gm-gate {
  position: fixed;
  inset: 0;
  z-index: var(--layer-modal);
  display: flex;
  flex-direction: column;
  background: var(--surface);
  padding-top: env(safe-area-inset-top, 0px);
}
.gm-gate-inner {
  flex: 1 1 auto;
  overflow-y: auto;
  padding: 18px 16px 8px;
}
.gm-gate-title { margin: 0 0 4px; font-size: var(--gm-t-head, 1.35rem); }
.gm-gate-say { margin: 0 0 18px; color: var(--text-secondary); }
.gm-gate-part { margin-bottom: 22px; }
.gm-gate-head {
  margin: 0 0 6px;
  font-size: var(--gm-t-body);
  text-transform: uppercase;
  letter-spacing: .07em;
  color: var(--text-secondary);
}
.gm-gate-foot {
  flex: none;
  display: flex;
  gap: 10px;
  padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
  border-top: 1px solid var(--border);
  background: var(--surface);
}
.gm-gate-foot button {
  flex: 1 1 0;
  min-height: 52px;
  border-radius: var(--radius);
  font: inherit;
  font-weight: 600;
  font-size: 1.0625rem;
}
.gm-gate-skip { border: 1px solid var(--border-strong); background: transparent; color: inherit; }
.gm-gate-go { border: 0; background: var(--action); color: var(--action-ink, #fff); }
.gm-gate-go:disabled { background: var(--surface-raised); color: var(--text-secondary); }

/* ============ THE ASK: THE ARRIVING ACT'S CHOICE, AND THE OTHER SIDE ======
 *
 * The choice reuses the handover screen's own shape, because it is the same
 * moment and the same kind of decision: a full screen at the point of going
 * live, with two full-width ways out pinned at the foot.
 *
 * THE QUESTION PUT TO THE ACT STILL PLAYING IS A MODAL, NOT A SCREEN. They are
 * mid-gig with a queue behind them, and taking the whole screen away from
 * somebody who is working would be the app shouldering in. It is over the
 * queue, it names nobody, and the line at the foot says out loud that ignoring
 * it costs nothing. */
.gm-askover { z-index: var(--layer-dialog); }
.gm-askmodal { max-width: 24rem; }
.gm-askmodal-title { margin: 0 0 6px; font-size: var(--step-2); font-weight: 600; }
.gm-askmodal-say { margin: 0 0 16px; color: var(--text-secondary); }
.gm-askmodal-row { display: flex; gap: 10px; }
.gm-askmodal-row button {
  flex: 1 1 0;
  min-height: 48px;
  border-radius: var(--radius);
  font: inherit;
  font-weight: 600;
}
/* THE HARMLESS ANSWER IS THE EASY ONE and is on the left, where a thumb lands
   first, because a musician glancing at this between songs must never feel
   they are being chased off their own gig. */
.gm-askmodal-foot {
  margin: 12px 0 0;
  font-size: var(--step-1);
  color: var(--text-secondary);
}
/* The banner is the tip banner, so it has no styles of its own beyond a hook
   for the suite to prove the two are drawn the same. */
.gm-handoverstrip { /* deliberately empty: see handoverStripMarkup() */ }

/* The act shape line on the Go Live form: one quiet line under the title, tapped
   to change. 44px tall because it is a tap target; no helper text. */
.gm-shape-line {
  display: inline-flex; align-items: center; gap: 10px; min-height: 44px; padding: 0 2px;
  margin: 0 0 4px; background: none; border: 0; color: inherit; font: inherit; font-size: 15px; cursor: pointer;
}
.gm-shape-change { text-decoration: underline; text-underline-offset: 3px; opacity: .85; }
