/* The Portal — base styling.
   Deliberately small and dependency-free: no framework, no CDN. Later slices
   add component styles; this is only enough to make the auth flow legible. */

/* ─────────────────────────────────────────────────────────────────────────────
   TWO THEMES: "One house" (dark) and "Daylight" (light).
   Names are Jeff's, from the Palette Lab session on 2026-08-18.
   (Since 2026-09-21 these are the DEFAULTS of two slots, and seven more themes
   follow the light block — see "MORE THEMES, AND THE TWO SLOTS THEY FILL".)

   One house rotates the Portal's neutrals to hue 30 and takes the marketing
   site's gold accent, so www and portal read as one brand — they had a nearly
   matching accent over divergent greys, which is the wrong way round. Daylight
   flips the Portal to a warm light ground; the marketing site stays dark,
   because a shop window and a tool you work in all evening have different jobs.

   ── EVERY COLOUR IS A TOKEN. THAT IS THE POINT, NOT TIDINESS. ──

   Before this, portal.css carried 26 rgba() literals and two hex ink values
   outside the token block: white overlays for hover, accent tints frozen at the
   OLD accent, and badge fills. Swapping only :root would have left all of them
   behind — the white overlays are invisible on a light ground, the accent tints
   would still be the previous orange fighting the new gold, and the ink on a
   button would stay dark on an accent that now needs light ink.

   So the overlays, the tints, the two shadows and the button ink are tokens as
   well, and each theme sets its own. A theme is now the block below and nothing
   else; there is no second place to remember.

   ── WHICH THEME IS ACTIVE ──

   Dark is the default ground, so :root carries One house and the light block
   overrides it. The choice is stamped as data-theme on <html> by the inline
   script in base.html BEFORE first paint — no @media (prefers-color-scheme)
   block here, deliberately, because honouring it in CSS means writing both
   palettes twice and letting them drift. That script resolves the OS preference
   instead, and only writes storage when somebody actually presses the switch,
   so the OS keeps being followed until they do.

   With JavaScript off nothing is stamped and the Portal is dark, which is
   exactly what it was before any of this.
   ───────────────────────────────────────────────────────────────────────────── */

:root {
  /* One house — dark */
  --bg: #1a1714;
  --surface: #27221b;
  --border: #3b332a;
  --text: #edeae8;
  --muted: #aea297;
  --accent: #c8974a;
  --special: #6fa8e8;   /* a scale's special note (2026-09-21) */
  --accent-hover: #daac64;
  /* Ink for text sitting ON the accent, not beside it. 6.94 against the gold. */
  --accent-ink: #1b1408;
  /* TWO deliberate departures from the stored One house palette, and only two.
     They are the same two the palette notes already recorded as AA-for-large-
     text-only, and the browser audit found exactly them and nothing else:

       danger  #e5534b → #ef6a60   4.26 on a card → 5.18
       success #3fa66a → #4fb87a   4.36 on a badge → 6.36

     Neither is large text. --danger is the word "Remove" on every destructive
     control at 14px; --success is the "Active" badge, at 12px, ten of them down
     the users screen. Both lifted within their own hue, nothing else touched.
     Revert these two lines to go back to the palette exactly as stored. */
  --danger: #ef6a60;
  --success: #4fb87a;
  --warning: #d9a441;
  --radius: 10px;

  /* Hover and fill washes. White on a dark ground; the light theme flips these
     to the text colour, which is the single change that stops every hover state
     disappearing. Six steps because six distinct weights are actually in use —
     snapping them to fewer would silently restyle screens nobody asked about. */
  --overlay-faint:  rgba(255, 255, 255, 0.02);
  --overlay-soft:   rgba(255, 255, 255, 0.03);
  --overlay:        rgba(255, 255, 255, 0.04);
  --overlay-mid:    rgba(255, 255, 255, 0.05);
  --overlay-strong: rgba(255, 255, 255, 0.06);
  --overlay-bold:   rgba(255, 255, 255, 0.08);

  /* Accent and semantic washes, tracking the accent above rather than frozen at
     whatever it happened to be when the rule was written. #c8974a / #3fa66a /
     #d9a441 / #e5534b as rgb, so the alpha can vary per use. */
  --tint-accent-faint:  rgba(200, 151, 74, 0.08);
  --tint-accent-soft:   rgba(200, 151, 74, 0.10);
  --tint-accent-mid:    rgba(200, 151, 74, 0.14);
  --tint-accent-strong: rgba(200, 151, 74, 0.22);
  --tint-success:        rgba(79, 184, 122, 0.12);
  --tint-warning-soft:   rgba(217, 164, 65, 0.10);
  --tint-warning:        rgba(217, 164, 65, 0.12);
  --tint-warning-strong: rgba(217, 164, 65, 0.20);
  --tint-danger:         rgba(239, 106, 96, 0.12);

  --shadow-menu: rgba(0, 0, 0, 0.45);
  --scrim:       rgba(0, 0, 0, 0.60);

  /* Placeholder text, dimmer than --muted. It has to be distinguishable from a
     REAL value at a glance — an empty tempo box showing a grey 100 reads as a
     song that is 100 bpm — and still legible, so it is not pushed as far as it
     could go. 3.17 against the input ground here, against 14.90 for a value
     actually typed in: about a fifth of the contrast, which is the gap that
     makes the difference obvious without making the hint unreadable. */
  --placeholder: #6f665c;

  /* Tells the BROWSER which way round this theme is, which no other declaration
     here can do. It is what draws a native <select>'s popup, a scrollbar, an
     autofilled field and the default focus ring dark — chrome the page does not
     own and cannot reach with a colour. Without it One house rendered every
     scrollbar light grey, including the three inside the guide editor, and the
     Key picker on the song form was a bright white widget in a dark form. */
  color-scheme: dark;
}

:root[data-theme="light"] {
  /* Daylight — light.

     The neutrals are the memory's values unchanged. The five colours that carry
     MEANING are not: they were chosen against a dark ground and measured 1.87
     to 3.89 against this one, which is below AA and in two cases below three to
     one. Shipping them would have made "Active" and "Expired" badges that are
     technically coloured and practically unreadable, so each moved down the
     same hue until it cleared 4.5 on BOTH the page and the card:

       accent  #a4661a → #855010   3.89 → 5.55
       success #3fa66a → #0f6b3c   2.54 → 5.49
       warning #d9a441 → #7d5205   1.87 → 5.68
       danger  #e5534b → #b3261e   3.08 → 5.44

     --accent-hover also changes direction. #d47f16 is LIGHTER than the accent,
     which is the right move on a dark ground and the wrong one here: a hover
     that fades into the page reads as a control going away. It darkens now. */
  --bg: #edeae4;
  --surface: #fbfaf7;
  --border: #d6d0c5;
  --text: #232019;
  /* The memory's #6c665b clears AA on the page (4.74) and the card (5.45) and
     then fails everywhere muted text sits over a TINT: the band context bar
     (4.33) and a tab count badge (4.31). Measured in the browser against the
     COMPOSITED grounds rather than from the token values, which is the only way
     that pair shows up at all — neither colour is in the other's rule. */
  --muted: #615b51;
  /* Taken one step past what the page and the card alone need — 5.55 and 6.38 — because
     the accent is also the colour of an ACTIVE tab, and a tab count badge sits
     on a darkened wash inside it. That composite ground is #e1e0dc, where the
     lighter candidate landed on 4.49. Nothing in either rule mentions the
     other; only measuring the rendered pair finds it. */
  --accent: #855010;
  --special: #2f6fb3;   /* a scale's special note (2026-09-21) */
  --accent-hover: #6f4210;
  --accent-ink: #fbfaf7;
  --danger: #b3261e;
  --success: #0f6b3c;
  --warning: #7d5205;
  --radius: 10px;

  /* The overlays invert. White at 4% on a white card is nothing at all — every
     table row hover, nav hover, flash background and secondary button would
     simply stop existing. Built from --text rather than pure black so they
     carry the same warmth as the rest of the theme, and lifted a little,
     because a dark wash on a light ground needs more alpha to read as the same
     strength of hint. */
  --overlay-faint:  rgba(35, 32, 25, 0.03);
  --overlay-soft:   rgba(35, 32, 25, 0.04);
  --overlay:        rgba(35, 32, 25, 0.06);
  --overlay-mid:    rgba(35, 32, 25, 0.07);
  --overlay-strong: rgba(35, 32, 25, 0.09);
  --overlay-bold:   rgba(35, 32, 25, 0.12);

  --tint-accent-faint:  rgba(133, 80, 16, 0.07);
  --tint-accent-soft:   rgba(133, 80, 16, 0.10);
  --tint-accent-mid:    rgba(133, 80, 16, 0.14);
  --tint-accent-strong: rgba(133, 80, 16, 0.20);
  --tint-success:        rgba(15, 107, 60, 0.12);
  --tint-warning-soft:   rgba(125, 82, 5, 0.10);
  --tint-warning:        rgba(125, 82, 5, 0.12);
  --tint-warning-strong: rgba(125, 82, 5, 0.18);
  --tint-danger:         rgba(179, 38, 30, 0.10);

  /* A near-black drop shadow on a light page reads as dirt. Both soften. */
  --shadow-menu: rgba(35, 32, 25, 0.18);
  --scrim:       rgba(35, 32, 25, 0.35);
  /* 3.29 against the light input ground, against 13.54 for a real value. */
  --placeholder: #877f73;
  color-scheme: light;
}

/* ─────────────────────────────────────────────────────────────────────────────
   MORE THEMES, AND THE TWO SLOTS THEY FILL — 2026-09-21

   Two attributes on <html> now, and they answer different questions:

     data-theme    "light" or "dark" — which SIDE of the switch you are on.
                   Everything that already asked "is this page light?" (the
                   pedalboard's studio sweep, the 3D stage, the picture caches)
                   keeps asking exactly this and is right for every theme.
     data-palette  WHICH theme on that side. Absent for One house and Daylight,
                   which are the two blocks above; one of the blocks below
                   otherwise. Each block is later in the file than both of those
                   at the same specificity, so it wins over whichever side of
                   the switch it sits on.

   Each person picks one dark theme and one light theme (CORE."UserPreference",
   PORTAL_THEME_DARK and PORTAL_THEME_LIGHT); the quick switch moves between the
   two. The list of themes is portal/pcw_portal/themes.py, and a theme added
   here needs a line there or nobody can choose it.

   The neon group came out of the Palette Lab: The Expanse's hand terminals
   and old neon signage. Each block was generated from the eleven colours the
   Lab exports, with the rest derived by the rules the two blocks above follow:
   white overlays on a dark ground and text-coloured ones on a light ground,
   the accent and status washes at the same alphas, the button ink taken from
   whichever of a dark or light tint of the accent reads better, and the
   placeholder walked down from --muted to about 3.2 against the page.

   Contrast was measured on the COMPOSITED grounds, the way Daylight's notes
   say it has to be: text, muted, accent and the three status colours against
   the card, the page, the strong accent wash and the boldest overlay. The two
   light themes clear 4.5 on every one of those, as Daylight does. The dark
   themes are at or above One house on every pair where One house is itself
   below 4.5 (a danger link on a flashed row, the accent on the strongest wash).

   --glow-edge, --glow-halo and --glow-inner are only read under
   [data-glow="on"], below. Each theme carries its own so the glow is always
   the colour of the accent it sits beside.
   ───────────────────────────────────────────────────────────────────────────── */

:root[data-palette="mcrn"] {
  /* MCRN terminal — dark, neon glow */
  --bg: #0a0608;
  --surface: #150b10;
  --border: #4a1626;
  --text: #f4e6ea;
  --muted: #b08c96;
  --accent: #ff3b76;
  --special: #6fa8e8;
  --accent-hover: #ff6795;
  --accent-ink: #1e050d;
  --danger: #ff5a4a;
  --success: #37e08a;
  --warning: #ffb23e;
  --overlay-faint:  rgba(255, 255, 255, 0.02);
  --overlay-soft:   rgba(255, 255, 255, 0.03);
  --overlay:        rgba(255, 255, 255, 0.04);
  --overlay-mid:    rgba(255, 255, 255, 0.05);
  --overlay-strong: rgba(255, 255, 255, 0.06);
  --overlay-bold:   rgba(255, 255, 255, 0.08);
  --tint-accent-faint:  rgba(255, 59, 118, 0.08);
  --tint-accent-soft:   rgba(255, 59, 118, 0.1);
  --tint-accent-mid:    rgba(255, 59, 118, 0.14);
  --tint-accent-strong: rgba(255, 59, 118, 0.22);
  --tint-success:        rgba(55, 224, 138, 0.12);
  --tint-warning-soft:   rgba(255, 178, 62, 0.1);
  --tint-warning:        rgba(255, 178, 62, 0.12);
  --tint-warning-strong: rgba(255, 178, 62, 0.2);
  --tint-danger:         rgba(255, 90, 74, 0.12);
  --shadow-menu: rgba(0, 0, 0, 0.55);
  --scrim:       rgba(0, 0, 0, 0.65);
  --placeholder: #735a61;
  --glow-edge: rgba(255, 59, 118, 0.55);
  --glow-halo: rgba(255, 59, 118, 0.28);
  --glow-inner: rgba(255, 59, 118, 0.06);
  color-scheme: dark;
}

:root[data-palette="wanted"] {
  /* Wanted bulletin — dark, neon glow */
  --bg: #0b0809;
  --surface: #1a0f11;
  --border: #5c4a14;
  --text: #f6f0d2;
  --muted: #bdb08a;
  --accent: #f4e04d;
  --special: #6fa8e8;
  --accent-hover: #fdec70;
  --accent-ink: #1e1b05;
  --danger: #ff6aa0;
  --success: #8be37a;
  --warning: #ffa53d;
  --overlay-faint:  rgba(255, 255, 255, 0.02);
  --overlay-soft:   rgba(255, 255, 255, 0.03);
  --overlay:        rgba(255, 255, 255, 0.04);
  --overlay-mid:    rgba(255, 255, 255, 0.05);
  --overlay-strong: rgba(255, 255, 255, 0.06);
  --overlay-bold:   rgba(255, 255, 255, 0.08);
  --tint-accent-faint:  rgba(244, 224, 77, 0.08);
  --tint-accent-soft:   rgba(244, 224, 77, 0.1);
  --tint-accent-mid:    rgba(244, 224, 77, 0.14);
  --tint-accent-strong: rgba(244, 224, 77, 0.22);
  --tint-success:        rgba(139, 227, 122, 0.12);
  --tint-warning-soft:   rgba(255, 165, 61, 0.1);
  --tint-warning:        rgba(255, 165, 61, 0.12);
  --tint-warning-strong: rgba(255, 165, 61, 0.2);
  --tint-danger:         rgba(255, 106, 160, 0.12);
  --shadow-menu: rgba(0, 0, 0, 0.55);
  --scrim:       rgba(0, 0, 0, 0.65);
  --placeholder: #69614d;
  --glow-edge: rgba(244, 224, 77, 0.55);
  --glow-halo: rgba(244, 224, 77, 0.28);
  --glow-inner: rgba(244, 224, 77, 0.06);
  color-scheme: dark;
}

:root[data-palette="comm"] {
  /* Comm panel — dark, neon glow */
  --bg: #060b0d;
  --surface: #0c1519;
  --border: #17434b;
  --text: #ddf5f6;
  --muted: #8fb3b8;
  --accent: #3de6d2;
  --special: #ff9ad5;
  --accent-hover: #5df2e1;
  --accent-ink: #051e1b;
  --danger: #ff5c7c;
  --success: #7ce38b;
  --warning: #ffc45a;
  --overlay-faint:  rgba(255, 255, 255, 0.02);
  --overlay-soft:   rgba(255, 255, 255, 0.03);
  --overlay:        rgba(255, 255, 255, 0.04);
  --overlay-mid:    rgba(255, 255, 255, 0.05);
  --overlay-strong: rgba(255, 255, 255, 0.06);
  --overlay-bold:   rgba(255, 255, 255, 0.08);
  --tint-accent-faint:  rgba(61, 230, 210, 0.08);
  --tint-accent-soft:   rgba(61, 230, 210, 0.1);
  --tint-accent-mid:    rgba(61, 230, 210, 0.14);
  --tint-accent-strong: rgba(61, 230, 210, 0.22);
  --tint-success:        rgba(124, 227, 139, 0.12);
  --tint-warning-soft:   rgba(255, 196, 90, 0.1);
  --tint-warning:        rgba(255, 196, 90, 0.12);
  --tint-warning-strong: rgba(255, 196, 90, 0.2);
  --tint-danger:         rgba(255, 92, 124, 0.12);
  --shadow-menu: rgba(0, 0, 0, 0.55);
  --scrim:       rgba(0, 0, 0, 0.65);
  --placeholder: #506669;
  --glow-edge: rgba(61, 230, 210, 0.55);
  --glow-halo: rgba(61, 230, 210, 0.28);
  --glow-inner: rgba(61, 230, 210, 0.06);
  color-scheme: dark;
}

:root[data-palette="nightdrive"] {
  /* Night drive — dark, neon glow */
  --bg: #0c0716;
  --surface: #150d24;
  --border: #3b2160;
  --text: #f2e8ff;
  --muted: #a996c8;
  --accent: #ff4fd8;
  --special: #6fd3ff;
  --accent-hover: #ff7be2;
  --accent-ink: #1e0519;
  --danger: #ff5470;
  --success: #3cf2c2;
  --warning: #ffd23f;
  --overlay-faint:  rgba(255, 255, 255, 0.02);
  --overlay-soft:   rgba(255, 255, 255, 0.03);
  --overlay:        rgba(255, 255, 255, 0.04);
  --overlay-mid:    rgba(255, 255, 255, 0.05);
  --overlay-strong: rgba(255, 255, 255, 0.06);
  --overlay-bold:   rgba(255, 255, 255, 0.08);
  --tint-accent-faint:  rgba(255, 79, 216, 0.08);
  --tint-accent-soft:   rgba(255, 79, 216, 0.1);
  --tint-accent-mid:    rgba(255, 79, 216, 0.14);
  --tint-accent-strong: rgba(255, 79, 216, 0.22);
  --tint-success:        rgba(60, 242, 194, 0.12);
  --tint-warning-soft:   rgba(255, 210, 63, 0.1);
  --tint-warning:        rgba(255, 210, 63, 0.12);
  --tint-warning-strong: rgba(255, 210, 63, 0.2);
  --tint-danger:         rgba(255, 84, 112, 0.12);
  --shadow-menu: rgba(0, 0, 0, 0.55);
  --scrim:       rgba(0, 0, 0, 0.65);
  --placeholder: #695b7f;
  --glow-edge: rgba(255, 79, 216, 0.55);
  --glow-halo: rgba(255, 79, 216, 0.28);
  --glow-inner: rgba(255, 79, 216, 0.06);
  color-scheme: dark;
}

:root[data-palette="arcade"] {
  /* Arcade — dark, neon glow */
  --bg: #07081a;
  --surface: #0e1030;
  --border: #2a2f6b;
  --text: #e8ebff;
  --muted: #979cc9;
  --accent: #5b8cff;
  --special: #ffb35c;
  --accent-hover: #87abff;
  --accent-ink: #050d1e;
  --danger: #ff4f7b;
  --success: #3ff29a;
  --warning: #ffcc3f;
  --overlay-faint:  rgba(255, 255, 255, 0.02);
  --overlay-soft:   rgba(255, 255, 255, 0.03);
  --overlay:        rgba(255, 255, 255, 0.04);
  --overlay-mid:    rgba(255, 255, 255, 0.05);
  --overlay-strong: rgba(255, 255, 255, 0.06);
  --overlay-bold:   rgba(255, 255, 255, 0.08);
  --tint-accent-faint:  rgba(91, 140, 255, 0.08);
  --tint-accent-soft:   rgba(91, 140, 255, 0.1);
  --tint-accent-mid:    rgba(91, 140, 255, 0.14);
  --tint-accent-strong: rgba(91, 140, 255, 0.22);
  --tint-success:        rgba(63, 242, 154, 0.12);
  --tint-warning-soft:   rgba(255, 204, 63, 0.1);
  --tint-warning:        rgba(255, 204, 63, 0.12);
  --tint-warning-strong: rgba(255, 204, 63, 0.2);
  --tint-danger:         rgba(255, 79, 123, 0.12);
  --shadow-menu: rgba(0, 0, 0, 0.55);
  --scrim:       rgba(0, 0, 0, 0.65);
  --placeholder: #5c5f81;
  --glow-edge: rgba(91, 140, 255, 0.55);
  --glow-halo: rgba(91, 140, 255, 0.28);
  --glow-inner: rgba(91, 140, 255, 0.06);
  color-scheme: dark;
}

:root[data-palette="dayglo"] {
  /* Day-glo — light */
  --bg: #f4eff6;
  --surface: #fdfbfe;
  --border: #dccfe5;
  --text: #1c1322;
  --muted: #665770;
  --accent: #a30f67;
  --special: #2f6fb3;
  --accent-hover: #7e0c4f;
  --accent-ink: #f8f1f5;
  --danger: #a91c2a;
  --success: #0c6437;
  --warning: #7a5300;
  --overlay-faint:  rgba(28, 19, 34, 0.03);
  --overlay-soft:   rgba(28, 19, 34, 0.04);
  --overlay:        rgba(28, 19, 34, 0.06);
  --overlay-mid:    rgba(28, 19, 34, 0.07);
  --overlay-strong: rgba(28, 19, 34, 0.09);
  --overlay-bold:   rgba(28, 19, 34, 0.12);
  --tint-accent-faint:  rgba(163, 15, 103, 0.07);
  --tint-accent-soft:   rgba(163, 15, 103, 0.1);
  --tint-accent-mid:    rgba(163, 15, 103, 0.14);
  --tint-accent-strong: rgba(163, 15, 103, 0.2);
  --tint-success:        rgba(12, 100, 55, 0.12);
  --tint-warning-soft:   rgba(122, 83, 0, 0.1);
  --tint-warning:        rgba(122, 83, 0, 0.12);
  --tint-warning-strong: rgba(122, 83, 0, 0.18);
  --tint-danger:         rgba(169, 28, 42, 0.1);
  --shadow-menu: rgba(28, 19, 34, 0.18);
  --scrim:       rgba(28, 19, 34, 0.35);
  --placeholder: #8e8296;
  --glow-edge: rgba(163, 15, 103, 0.55);
  --glow-halo: rgba(163, 15, 103, 0.28);
  --glow-inner: rgba(163, 15, 103, 0.06);
  color-scheme: light;
}

:root[data-palette="daycomm"] {
  /* Comm panel, daylight — light */
  --bg: #eaf2f2;
  --surface: #fafdfd;
  --border: #c7dadb;
  --text: #0f1d20;
  --muted: #4a5f63;
  --accent: #00655f;
  --special: #9c2f6e;
  --accent-hover: #003c39;
  --accent-ink: #f1f8f8;
  --danger: #a82236;
  --success: #136b39;
  --warning: #735000;
  --overlay-faint:  rgba(15, 29, 32, 0.03);
  --overlay-soft:   rgba(15, 29, 32, 0.04);
  --overlay:        rgba(15, 29, 32, 0.06);
  --overlay-mid:    rgba(15, 29, 32, 0.07);
  --overlay-strong: rgba(15, 29, 32, 0.09);
  --overlay-bold:   rgba(15, 29, 32, 0.12);
  --tint-accent-faint:  rgba(0, 101, 95, 0.07);
  --tint-accent-soft:   rgba(0, 101, 95, 0.1);
  --tint-accent-mid:    rgba(0, 101, 95, 0.14);
  --tint-accent-strong: rgba(0, 101, 95, 0.2);
  --tint-success:        rgba(19, 107, 57, 0.12);
  --tint-warning-soft:   rgba(115, 80, 0, 0.1);
  --tint-warning:        rgba(115, 80, 0, 0.12);
  --tint-warning-strong: rgba(115, 80, 0, 0.18);
  --tint-danger:         rgba(168, 34, 54, 0.1);
  --shadow-menu: rgba(15, 29, 32, 0.18);
  --scrim:       rgba(15, 29, 32, 0.35);
  --placeholder: #788a8c;
  --glow-edge: rgba(0, 101, 95, 0.55);
  --glow-halo: rgba(0, 101, 95, 0.28);
  --glow-inner: rgba(0, 101, 95, 0.06);
  color-scheme: light;
}

/* ── THE NEON GLOW ────────────────────────────────────────────────────────────
   Lit edges and a halo, after the hand terminals in The Expanse. On for the
   dark neon themes and off for everything else — a halo on a white card reads
   as a smudge, not a light. Stamped as data-glow="on" by base.html's head
   script from the theme's entry in themes.py.

   Deliberately NOT on buttons. A plain <button> is the accent button in this
   file, and dozens of classed buttons opt out of that look; a halo cannot tell
   which is which, and would light up every link-styled control on the page. */
:root[data-glow="on"] .card,
:root[data-glow="on"] dialog {
  border-color: var(--glow-edge);
  box-shadow: 0 0 0 1px var(--glow-edge), 0 0 22px var(--glow-halo),
              inset 0 0 28px var(--glow-inner);
}
/* A card inside a card would stack two halos into one bright smear. The outer
   one carries the light; the inner one keeps a lit edge and nothing else. */
:root[data-glow="on"] .card .card { box-shadow: 0 0 0 1px var(--glow-edge); }
:root[data-glow="on"] .topbar {
  border-bottom-color: var(--glow-edge);
  box-shadow: 0 1px 14px var(--glow-halo);
}
:root[data-glow="on"] .navmenu {
  border-color: var(--glow-edge);
  box-shadow: 0 0 18px var(--glow-halo), 0 10px 24px var(--shadow-menu);
}
:root[data-glow="on"] h1 { text-shadow: 0 0 12px var(--glow-halo); }
:root[data-glow="on"] .badge { box-shadow: 0 0 9px -2px currentColor; }

* { box-sizing: border-box; }

body {
  margin: 0;
  min-height: 100vh;
  background: var(--bg);
  color: var(--text);
  font: 16px/1.5 "Segoe UI", system-ui, -apple-system, sans-serif;
}

/* Centred pages (login, change password).
   flex-direction MUST be column. The top bar is a sibling of <main>, so a
   default row direction lays the header and the card out side by side — which
   is invisible on the signed-out login page and obvious the moment a signed-in
   user is sent here by the MustChangePassword redirect. */
body.centred {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
}

body.centred main {
  flex: 1;
  display: flex;
  flex-direction: column;   /* stack flashes above the card, not beside it */
  align-items: center;
  justify-content: center;
  width: 100%;
  max-width: none;
  padding: 2rem 1rem;
}

/* Constrain the card and any flash list to the same readable width. */
body.centred main > * { width: 100%; max-width: 26rem; }

/* ---------- top bar ----------
   One row and nothing else. The brand that used to open it is gone — it named
   the software to somebody already inside it, and the row's left end is worth
   more to the band. The band context strip that ran underneath went with it;
   it is the bands menu now.

   The BREADCRUMB moved into it on 2026-09-17 (see base.html), so the row is now
   the band, where you are, and you. It was its own strip underneath, which cost
   every screen in the Portal about 40px of height to say what fits beside two
   short controls. */
.topbar {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.35rem 1rem;
  background: var(--surface);
  border-bottom: 1px solid var(--border);
}

/* ---------- layout ----------
   No fixed measure. `max-width: 60rem` centred every screen in a 960px column
   and left the rest of a 27-inch monitor as margin — which is the right call for
   a page of prose and the wrong one for a library of two hundred songs, where
   the whole question is how many fit across.

   Reading width has NOT been given up; it has been moved to the things that
   need it. .stacked-form caps at 32rem and .narrow-card at 34rem, so a form is
   still a column however wide the window is, and only the content that gains
   from the room — the tile grids, the tables — actually takes it.

   The padding scales with the viewport rather than sitting at a fixed 1.5rem:
   on a phone that is 1rem of gutter instead of a fifth of the screen, and on a
   wide monitor the content stops short of the bezel. */
main {
  padding: 1.5rem clamp(0.75rem, 2.5vw, 2.5rem);
  margin: 0 auto;
}
body.centred main { padding: 0; }

.card {
  position: relative;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 2rem;
}

h1 { margin: 0 0 0.25rem; font-size: 1.5rem; }
h2 { font-size: 1.05rem; margin: 1.75rem 0 0.5rem; color: var(--muted); font-weight: 600; }
/* Explanatory prose keeps a reading measure now that the page around it does
   not. A sentence set across a 27-inch monitor is one the eye loses its place in
   on the way back to the left margin; the grids and tables beside it are the
   things that wanted the width. */
.sub { margin: 0 0 1.5rem; color: var(--muted); font-size: 0.92rem; max-width: 44rem; }
.muted { color: var(--muted); }

/* ---------- forms ---------- */
label {
  display: block;
  margin: 1rem 0 0.35rem;
  font-size: 0.85rem;
  color: var(--muted);
}

input[type="text"],
input[type="email"],
input[type="number"],
input[type="date"],
input[type="password"] {
  width: 100%;
  padding: 0.65rem 0.75rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text);
  font-size: 1rem;
}

textarea {
  width: 100%;
  padding: 0.65rem 0.75rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text);
  font: inherit;
  font-size: 1rem;
  resize: vertical;
}

/* ── The pickers, which had no rule at all ──
   Every other field above was styled from the beginning; `select`, `checkbox`
   and `radio` were left to the browser, and it showed. On the song form a 41px
   themed input sat directly above a 19px white one, and in One house that
   picker was a light-mode widget in a dark form — the first thing the eye
   landed on, on the screen a band uses most.

   `width` is deliberately NOT set. A text input is full width because a name
   or an email is as long as it is; a picker is exactly as wide as its longest
   option, and forcing it to fill the measure is how a two-item Yes/No dropdown
   ends up 800px across. The forms that want a full-width picker say so on the
   field, below. This is the same trap `button { width: 100% }` set. */
select {
  max-width: 100%;
  padding: 0.6rem 2.1rem 0.6rem 0.75rem;
  background-color: var(--bg);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text);
  font: inherit;
  font-size: 1rem;
  line-height: 1.4;
  cursor: pointer;
  /* The arrow is drawn here because the native one goes with the native
     control. Two gradients rather than an SVG data URI: a URI cannot carry
     currentColor, so the mark would be frozen at one theme's ink. */
  appearance: none;
  -webkit-appearance: none;
  background-image:
    linear-gradient(45deg, transparent 50%, var(--muted) 50%),
    linear-gradient(135deg, var(--muted) 50%, transparent 50%);
  background-size: 5px 5px, 5px 5px;
  background-position: right 1.05rem center, right 0.7rem center;
  background-repeat: no-repeat;
}
select:hover { border-color: var(--muted); }
select:focus {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
  border-color: var(--accent);
}
select:disabled { color: var(--muted); cursor: default; opacity: 1; }

/* A picker inside a stacked form field lines up with the inputs above and
   below it — there the full measure IS what the eye expects. */
.field select, .stacked-form select, .form-row select { width: 100%; }

/* 13×13 is what a browser gives these, which is under half the 44px a finger
   wants and small enough on a stand to miss in the dark. accent-color was
   already being set in four separate places for the same reason; it belongs
   here, once. */
input[type="checkbox"],
input[type="radio"] {
  width: 1.15rem;
  height: 1.15rem;
  margin: 0 0.15rem 0 0;
  accent-color: var(--accent);
  vertical-align: -0.2em;
  cursor: pointer;
}
input[type="checkbox"]:focus-visible,
input[type="radio"]:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
/* A checkbox with a label beside it: the whole pair is the target, and the
   label carries the padding so the row does not grow. */
label:has(> input[type="checkbox"]),
label:has(> input[type="radio"]) { cursor: pointer; }

/* There was no placeholder rule at all, so every browser used its own default —
   which is roughly --text at 54% opacity and sits far too close to a value
   somebody actually typed. */
::placeholder { color: var(--placeholder); opacity: 1; }

input:focus,
textarea:focus {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
  border-color: var(--accent);
}

/* ── A button is as wide as its label ──
   This rule used to say `width: 100%; margin-top: 1.5rem`, which is right for
   the one button on a login card and wrong for every button added since.
   Thirty-three rules elsewhere in this file existed only to undo it, and the
   three screens where nobody remembered carried a full-bleed accent bar: "Add a
   set" was 1287px across a 1440px setlist, and "Leave Marble Arch" — the one
   irreversible thing on the band screen — was the widest element on the page.

/* ── THE hidden ATTRIBUTE HAS TO WIN, AND WITHOUT THIS IT DOES NOT ──
   MEASURED 2026-09-03 on the staging lighting screens: a Cancel button marked
   `hidden` was visible on a form nobody had started editing.

   The browser's own stylesheet says `[hidden] { display: none }`, but that is
   a UA rule and ANY author rule setting `display` beats it. `.linkish` sets
   `display: inline-flex`, so every `<button class="linkish" hidden>` in the
   Portal has been showing regardless — seven of them, across the band screens,
   both MIDI screens and both lighting ones.

   `!important` rather than raising specificity, because the point of `hidden`
   is that it is absolute: an element marked hidden is not rendered, and no
   component style should be able to argue with that. It is the one thing in
   this file that genuinely earns it. */
[hidden] { display: none !important; }

/* === THE SCENE EDITOR =====================================================
   lighting/scene.html: the four-up on one side, one light's controls on the
   other. The preview STRIP that used to sit above both — a row of coloured
   circles, one per fixture — was removed 2026-09-08 along with its .scene-strip
   and .scene-swatch rules; the template's header says why, and the short form
   is that the stage answers the same question in the room the band stands in.

   ── THE SPLIT IS BY ORIENTATION, WHICH IS THE UNUSUAL PART ──

   Almost everything else in this file breaks on width alone. This one asks the
   viewport which way round it is, because the thing being balanced is a picture
   against a column of controls and what decides whether they fit side by side is
   whether there is HEIGHT to give the picture. A 1024x768 tablet held landscape
   has room for both; the same tablet turned portrait does not, and it is the
   same number of pixels wide.

   The width floor is still there and it is doing a job: a phone in landscape is
   wider than 46rem and has 375px of height, so orientation alone would put a
   four-pane picture and an editing column into a strip two thumbs tall. 62rem
   clears a tablet's 1024 and every desktop, and leaves every phone stacked. */
/* No rule across the top and no air above it since 2026-09-17: the <h1> that
   used to sit there is gone -- the breadcrumb names the scene -- and a rule
   with nothing above it is a line drawn under the top of the card. */
.scene-work {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 1.25rem;
}
.scene-work-stage, .scene-work-panel { min-width: 0; }

/* ── THE ROOM TAKES THE WIDTH, AND THE CONTROLS FLOAT OVER IT ── (2026-09-15)
   One light's controls used to be a column beside the four-up. They are a
   dialog over it now (stage_editor.js places it, lighting.js fills it), so the
   stage has the whole width and the panes take their height from the window,
   as on the stage editor. A band with no current stage has nothing to float a
   dialog over and keeps the controls in the page. */
/* Nothing to keep clear of, so nothing above the picture at all. (Was 0.5rem
   of margin and a rule; both went with the title on 2026-09-17.) */
/* ── THE LIGHT LIST, IN THE ROOM'S CORNER ── (2026-09-15, later)
   Over the top-left of the first pane's picture -- the room when it is
   maximized, the plan with four up -- on a dark glass so it reads over a lit
   stage and a dark one alike. Under the dialog (20), which may open on this
   side and name the light itself. Its options take the page's colours, since
   the list they drop into is drawn by the browser rather than over the room. */
.scene-fixture-pick {
  position: absolute; z-index: 15;
  /* Half a rem inside the picture, which is where the top of it is now: the
     caption row this used to clear went on 2026-09-17. */
  top: 0.5rem; left: 0.5rem;
  width: auto; max-width: min(16rem, calc(100% - 1rem)); margin: 0;
  padding: 0.3rem 1.9rem 0.3rem 0.55rem;
  font-size: 0.85rem;
  background-color: rgba(12, 13, 15, 0.72);
  color: #e8eaed;
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: var(--radius);
}
.scene-fixture-pick option { color: var(--text); background: var(--surface); }

/* ── SCENE MACROS ── (scene macros pass 1, 2026-09-24)
   docs/decisions/scene-macros.md. The cards sit in a strip above the room,
   the lights in a row of tiles under it, and a card is dragged (or tapped,
   then a tile tapped) onto a light. Both strips are one row that scrolls
   sideways, so neither pushes the room off the screen however many lights a
   band has -- the room's height below allows for exactly these two rows.

   Colour and Movement cards carry their category's colour as a left edge, and
   the same edge marks each macro on a tile, so the two read as one thing. */
.macro-strip {
  display: flex; flex-wrap: nowrap; align-items: center; gap: 0.9rem;
  overflow-x: auto; padding: 0.1rem 0 0.2rem;
  min-height: 2.4rem;
}
.macro-group { display: flex; align-items: center; gap: 0.4rem; flex: none; }
.macro-group-name {
  font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em;
  color: var(--muted);
}
.macro-card, .macro-ghost {
  width: auto; margin: 0;
  padding: 0.35rem 0.75rem 0.35rem 0.6rem;
  font-size: 0.85rem; white-space: nowrap;
  background: var(--surface); color: var(--text);
  border: 1px solid var(--border); border-left: 4px solid var(--muted);
  border-radius: 6px;
  cursor: grab; touch-action: none; user-select: none;
}
.macro-card:hover { background: var(--overlay-bold); }
.macro-card[aria-pressed="true"] { outline: 2px solid var(--accent); outline-offset: 1px; }
.macro-card--color, .macro-chip--color { border-left-color: #d9579a; }
.macro-card--movement, .macro-chip--movement { border-left-color: #4f9fd9; }
.macro-card--intensity, .macro-chip--intensity { border-left-color: #e0a23a; }
.macro-strip-hint { font-size: 0.8rem; flex: none; }
.macro-ghost {
  position: fixed; z-index: 1000; pointer-events: none;
  transform: translate(-50%, -120%);
  box-shadow: 0 6px 18px var(--shadow-menu);
}
body.is-dragging-macro, body.is-dragging-macro * { cursor: grabbing !important; }

.macro-tiles-wrap { min-width: 0; }
.macro-tiles {
  display: flex; flex-wrap: nowrap; gap: 0.6rem;
  overflow-x: auto; list-style: none; margin: 0; padding: 0 0 0.3rem;
}
.macro-tile {
  flex: none; width: 14.5rem; height: 7.4rem; overflow-y: auto;
  padding: 0.45rem 0.5rem;
  background: var(--surface);
  border: 1px solid var(--border); border-radius: var(--radius);
}
.macro-tiles.is-armed .macro-tile { cursor: copy; border-style: dashed; border-color: var(--accent); }
.macro-tile.is-target { border-color: var(--accent); background: var(--tint-accent-mid); }
.macro-tile-head { display: flex; align-items: center; gap: 0.45rem; margin-bottom: 0.3rem; }
.macro-tile-picture { flex: none; }
.macro-tile-picture .scene-thumb.is-small { width: 3.4rem; }
.macro-tile-name { font-weight: 600; font-size: 0.85rem; min-width: 0;
                   overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.macro-tile-count { margin-left: auto; font-size: 0.72rem; flex: none; }
.macro-tile-empty { margin: 0.3rem 0 0; font-size: 0.8rem; }
.macro-stack { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.25rem; }
.macro-chip {
  /* Up and down still scroll the tile; sideways is a drag to another tile. */
  touch-action: pan-y;
  display: flex; align-items: stretch; gap: 0.2rem;
  border: 1px solid var(--border); border-left: 4px solid var(--muted);
  border-radius: 6px; background: var(--overlay);
}
.macro-chip-open {
  flex: 1; min-width: 0; width: auto; margin: 0;
  display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap;
  padding: 0.2rem 0.35rem; text-align: left;
  background: none; border: 0; color: var(--text); font-size: 0.78rem; cursor: pointer;
}
.macro-chip-open:hover:not(:disabled) { background: var(--overlay-bold); }
.macro-chip-open:disabled { cursor: default; color: var(--text); }
.macro-chip-name { font-weight: 600; }
.macro-chip-detail { color: var(--muted); font-size: 0.72rem; }
.macro-swatch {
  display: inline-block; width: 0.8rem; height: 0.8rem; flex: none;
  border-radius: 3px; border: 1px solid var(--border);
}
.macro-chip-moves { display: flex; flex-direction: column; }
.macro-chip-move {
  width: auto; margin: 0; padding: 0 0.3rem; line-height: 1;
  background: none; border: 0; color: var(--muted); font-size: 0.7rem; cursor: pointer;
}
.macro-chip-move:hover:not(:disabled) { color: var(--accent); }
.macro-chip-move:disabled { opacity: 0.3; cursor: default; }
.macro-play { display: flex; align-items: center; gap: 0.6rem; min-height: 1.2rem; font-size: 0.8rem; }
.macro-play .macro-bpm { display: inline-flex; align-items: center; gap: 0.3rem; margin: 0; font-size: 0.8rem; }
.macro-play .macro-bpm input { width: 5rem; padding: 0.15rem 0.3rem; }

/* The layer dialog sits at the side and leaves the room visible behind it --
   no scrim -- because the room is the preview of what it is changing. */
dialog.layer-dialog { margin-right: 1.25rem; width: min(24rem, calc(100vw - 2rem)); }
dialog.layer-dialog::backdrop { background: transparent; }
.layer-dialog .layer-row {
  display: grid; grid-template-columns: 6.5rem minmax(0, 1fr);
  align-items: center; gap: 0.5rem; margin-top: 0.6rem;
}
.layer-dialog .layer-row label { margin: 0; }
.layer-dialog fieldset { border: 0; padding: 0; margin: 0; min-width: 0; }
.layer-dialog input[type="color"] { width: 4rem; height: 2rem; padding: 0.1rem; }
.layer-dialog .layer-dial { display: flex; align-items: center; gap: 0.5rem; }
.layer-dialog .layer-dial input[type="number"] { width: 6rem; }
.layer-dialog .unit { color: var(--muted); font-size: 0.8rem; }
.layer-dialog .layer-blend-note { margin: 0.25rem 0 0 7rem; }
/* Pass 2: Breath's and Pan Loop's fields. */
.layer-dialog .layer-indent { margin: 0.25rem 0 0 7rem; }
.layer-dialog .layer-label { font-size: 0.9rem; }
.layer-dialog .layer-choice { display: grid; gap: 0.2rem; }
.layer-dialog .layer-choice label.check { margin: 0; font-size: 0.85rem; }
.layer-dialog .layer-choice input { width: auto; }
.layer-points { width: 100%; margin-top: 0.6rem; border-collapse: collapse; font-size: 0.82rem; }
.layer-points th { text-align: left; font-weight: 500; color: var(--muted); padding: 0 0.25rem 0.2rem; }
.layer-points td { padding: 0.15rem 0.25rem; }
.layer-points td input { width: 100%; padding: 0.3rem 0.4rem; font-size: 0.85rem; }
.layer-points .layer-point-n { color: var(--muted); }
.layer-points .js-point-remove { font-size: 1rem; }
.layer-points input[type="color"] { width: 100%; height: 1.8rem; padding: 0.1rem; }
.layer-dialog .layer-dial select { width: auto; flex: 1; min-width: 0; }
.layer-dialog .layer-dial button { width: auto; margin: 0; }
/* Pass 5: a macro on a tile can be dragged to another tile, which copies it. */
.macro-chip.is-copy-source { outline: 2px dashed var(--accent); outline-offset: 1px; }
.macro-chip-swatches { display: inline-flex; gap: 1px; }
.macro-chip-swatches .macro-swatch { width: 0.5rem; }
.layer-dialog .layer-warn { color: var(--warning); }
/* Pass 4: groups. */
.layer-dialog fieldset.layer-settings { border: 0; padding: 0; margin: 0; min-width: 0; }
.layer-dialog .layer-group-fields {
  margin-top: 0.6rem; padding: 0.5rem 0.6rem;
  border: 1px solid var(--border); border-radius: 6px;
}
.layer-members { list-style: none; margin: 0.4rem 0 0; padding: 0; display: grid; gap: 0.2rem; font-size: 0.85rem; }
.layer-members li { display: flex; align-items: center; gap: 0.4rem; }
.layer-members .member-rank { color: var(--muted); width: 1.2rem; }
.layer-members .member-name { flex: 1; }
.macro-group-badge {
  font-size: 0.68rem; padding: 0 0.3rem; border-radius: 4px;
  border: 1px solid var(--border); color: var(--muted);
}
.macro-group-badge.is-leader { color: var(--accent); border-color: var(--accent); }
.macro-tile-select { width: auto; margin: 0; flex: none; }
.macro-tile.is-selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; }
@media (max-width: 46rem) {
  dialog.layer-dialog { margin-right: auto; }
  .layer-dialog .layer-blend-note, .layer-dialog .layer-indent { margin-left: 0; }
}

/* The room gives up exactly the two strips' height when they are there. */
.scene-work.has-stage.has-macros .stage-canvas { height: max(12rem, calc((100vh - 26rem) / 2)); }
.scene-work.has-stage.has-macros .stage-views.is-max .stage-canvas { height: max(18rem, calc(100vh - 25rem)); }
.scene-dialog-note { margin: 0 0 0.5rem; font-size: 0.8rem; }
@media (max-width: 46rem) {
  /* On a phone the picture is one pane under a switcher, and a list laid over
     it would cover what little of the room there is. */
  .scene-fixture-pick {
    position: static; display: block; max-width: 100%;
    margin: 0 0 0.4rem;
    background-color: var(--bg); color: var(--text); border-color: var(--border);
  }
}
/* Measured, as the stage editor's are, and measured AGAIN on 2026-09-17 when
   the title, the caption rows and the link back all went and the breadcrumb
   moved into the top bar: the views now start 123px down a 1400 x 950 window
   rather than 236, and the room's picture starts there too rather than under a
   caption. These end both arrangements about 20px above the bottom of the
   window. (It was 409px down while a sentence and a toolbar sat above them.) */
.scene-work.has-stage .stage-canvas { height: max(13rem, calc((100vh - 15.5rem) / 2)); }
.scene-work.has-stage .stage-views.is-max .stage-canvas { height: max(20rem, calc(100vh - 14.5rem)); }
/* Narrower than the stage editor's: it holds one light's values and nothing
   else, and every inch it takes is an inch of the room it covers. */
/* Two classes, because .stage-dialog's 23rem comes later in this file and one
   class would lose to it. */
.stage-dialog.scene-dialog {
  width: min(19.5rem, calc(100% - 1rem));
  /* Clear of the tools chip, which sits over the top-right corner of the
     picture on this screen -- see .stage-views.is-bare. The dialog opens on
     whichever side is away from the press that picked the light, so on a pick
     from the list it opens right, exactly where House lights is. (2026-09-17)
     It is `top` rather than a z-index fight: the chip is a control and the
     dialog is a control, and one covering the other is a control nobody can
     reach either way. */
  top: 3.1rem;
  max-height: calc(100% - 3.7rem);
}
.scene-dialog-title {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.15rem 0.45rem;
  min-width: 0;
}
.scene-dialog-title .muted { font-size: 0.8rem; }
/* The dialog's head names the light, so the block's own heading would say it
   twice. */
.scene-dialog .scene-fixture-head { display: none; }
.scene-dialog .scene-fixture { padding: 0; }
.scene-dialog .scene-colour { margin-bottom: 0.5rem; padding: 0.35rem 0.5rem; }

/* Which light is being set. Above the controls rather than beside them: it
   changes what the whole column is about, and a picker that reads as one more
   field in the form is one people miss. */
.scene-pick {
  display: flex; align-items: center; gap: 0.6rem;
  margin: 0.9rem 0 0.4rem;
}
.scene-pick label { margin: 0; flex: none; font-weight: 600; }
.scene-pick select { flex: 1 1 auto; min-width: 0; }

/* One fixture's controls. Every fixture is rendered and all but one carry
   `hidden` — see _scene_values.html — so this is styling a block that is on
   screen alone rather than one row of a list. */
.scene-fixture { padding: 0.4rem 0 0; scroll-margin: 5rem 0; }
.scene-fixture-head {
  margin: 0 0 0.4rem; font-size: 0.95rem;
  display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: baseline;
}
.scene-fixture-head .muted { font-size: 0.8rem; font-weight: 400; }

/* ── COLOUR, AS ONE CONTROL OVER THREE ──
   Only drawn for a fixture whose profile maps red, green AND blue. It sits
   above the sliders it writes, because it is a shortcut through three of them
   rather than a fourth thing to set. */
.scene-colour {
  display: flex; align-items: center; gap: 0.6rem;
  margin: 0 0 0.75rem;
  padding: 0.5rem 0.6rem;
  background: var(--overlay-faint);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.scene-colour label { margin: 0; flex: none; font-size: 0.8rem; color: var(--muted); }

/* ── ONE GROUP PER PART OF THE BOX ── (2026-09-15)
   A spider's controls come as "Whole fixture", "Bar 1" and "Bar 2", each with
   its own swatch; a fixture with no parts is one group with no heading. */
.scene-group + .scene-group { margin-top: 0.6rem; }
.scene-group-head {
  margin: 0 0 0.35rem; font-size: 0.75rem; font-weight: 600;
  letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted);
}
/* A native swatch is a tiny inset rectangle in most browsers; this gives it a
   size a finger can hit and a border so it is visibly a control rather than a
   coloured gap in the row. */
.scene-colour input[type="color"] {
  width: 3rem; height: 2.25rem; padding: 0.15rem;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--bg); cursor: pointer;
}
.scene-colour .linkish { margin-left: auto; }

/* ── ONE ATTRIBUTE: A SLIDER, A BOX AND A WAY BACK TO EMPTY ──
   The slider is the hand and the box is the value; lighting.js says why. The
   box is narrow on purpose — three digits is the whole of what it holds, and
   giving it more width takes it off the slider it belongs to. */
.scene-attr {
  padding: 0.45rem 0;
  border-bottom: 1px solid var(--border);
}
.scene-attr:last-child { border-bottom: none; }
.scene-attr-top {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 0.5rem;
}
.scene-attr-name { margin: 0; font-size: 0.8rem; color: var(--muted); }
/* "not set" is drawn only when it is true. An attribute set to 0 and one the
   scene says nothing about put the slider in exactly the same place, and that
   difference is the whole of how scenes layer — so it has to be in words. */
.scene-attr-state {
  font-size: 0.7rem; color: var(--muted);
  text-transform: uppercase; letter-spacing: 0.05em;
  visibility: hidden;
}
.scene-attr.is-unset .scene-attr-state { visibility: visible; }
.scene-attr-controls {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 4.25rem auto;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0.2rem;
}
.scene-attr-controls input[type="range"] {
  width: 100%; margin: 0; accent-color: var(--accent);
}
/* Dimmed rather than moved: the slider of an unset attribute is sitting at 0
   because it has nowhere else to sit, and looking half-off says that better
   than a track that looks deliberately set to nothing. */
.scene-attr.is-unset input[type="range"] { opacity: 0.4; }
.scene-attr-controls input[type="number"] {
  width: 100%; margin: 0;
  font-variant-numeric: tabular-nums;
}
.scene-attr-controls .linkish { padding: 0.2rem 0.3rem; font-size: 0.78rem; }

/* ── DIALS INSTEAD OF FADERS ── (2026-09-15)
   .scene-dials is put on #scene-values by lighting.js when SCENE_CONTROLS is
   "dial". Each attribute becomes a small tile -- its name, the dial, the exact
   box and Clear -- three to a row in the dialog, so a four-channel PAR is two
   short rows where four faders were a column. */
.scene-dials .scene-attr-list {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(5.1rem, 1fr));
  gap: 0.4rem;
}
.scene-dials .scene-attr,
.scene-dials .scene-attr:last-child {
  display: flex; flex-direction: column; align-items: center; gap: 0.15rem;
  padding: 0.35rem 0.25rem 0.25rem;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--overlay-faint); text-align: center;
}
.scene-dials .scene-attr-top { flex-direction: column; align-items: center; gap: 0; }
.scene-dials .scene-attr-name { font-size: 0.75rem; line-height: 1.2; }
.scene-dials .scene-attr-state { font-size: 0.6rem; }
.scene-dials .scene-attr-controls {
  display: flex; flex-direction: column; align-items: center; gap: 0.2rem; margin: 0;
}
.scene-dials .scene-attr-controls input[type="number"] {
  width: 3.6rem; padding: 0.15rem 0.25rem; text-align: center; font-size: 0.85rem;
}
.scene-dials .scene-attr-controls .linkish { font-size: 0.72rem; padding: 0 0.2rem; }
/* Dimmed rather than moved, as the fader was: an unset attribute's dial sits at
   0 because it has nowhere else to sit. */
.scene-dials .scene-attr.is-unset .rotary { opacity: 0.4; }

/* ── ROTARY DIALS ── (2026-09-15)
   static/js/rotary.js: a dial laid over an <input type="range">, which stays in
   the page as the value. Generic on purpose -- nothing here knows about scenes,
   so the day dials are wanted on another screen this is already written. */
.rotary {
  display: inline-block; flex: none;
  width: 2.9rem; height: 2.9rem;
  border-radius: 50%;
  cursor: ns-resize; touch-action: none;
  -webkit-user-select: none; user-select: none;
}
.rotary svg { display: block; width: 100%; height: 100%; overflow: visible; }
.rotary-track, .rotary-value { fill: none; stroke-width: 9; stroke-linecap: round; }
.rotary-track { stroke: var(--border); }
.rotary-value { stroke: var(--accent); }
.rotary-knob { fill: var(--surface); stroke: var(--border); stroke-width: 2; }
.rotary-pointer { stroke: var(--text); stroke-width: 7; stroke-linecap: round; }
.rotary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.rotary[aria-disabled="true"] { cursor: default; opacity: 0.5; }
/* The range underneath: still in the page and still the value, never seen.
   !important on the size, because whatever screen it is on already styles its
   ranges -- the scene screen's `.scene-attr-controls input[type="range"]` sets
   width: 100% at higher specificity, and an invisible 310px slider scrolled
   the whole dialog sideways. A hidden element must not be sizeable by anyone. */
.rotary-source {
  position: absolute !important;
  width: 1px !important; height: 1px !important;
  min-width: 0 !important; margin: 0 !important; padding: 0 !important;
  opacity: 0; pointer-events: none; overflow: hidden;
}

/* A plpgsql error context is a call stack: multi-line, sometimes very wide, and
   the line breaks carry the meaning. It scrolls inside its OWN box rather than
   widening the page — a log viewer that makes the whole document scroll
   sideways is one nobody can read the rest of. */
pre.log-context {
  margin: 0.4rem 0 0;
  padding: 0.6rem 0.7rem;
  overflow-x: auto;
  max-height: 18rem;
  overflow-y: auto;
  font-size: 0.78rem;
  line-height: 1.45;
  white-space: pre;
  background: var(--overlay-mid);
  border-radius: 4px;
  color: var(--muted);
}

/*
   A default that has to be undone thirty-three times is not a default. The
   auth cards say what they want instead, through .btn-block below, and they
   are the minority they always were. */
button {
  width: auto;
  margin-top: 0;
  padding: 0.7rem 1rem;
  background: var(--accent);
  color: var(--accent-ink);
  border: 0;
  border-radius: 6px;
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
}

/* :where() so this carries NO specificity beyond the element itself.
   `button:hover` is 0,1,1 and beat every class that had opted out of the button
   look — .block-card-open sets `background: none` at 0,1,0, so hovering a vibe,
   an invite or a template card filled the whole card with the full accent
   colour and left its text unreadable. Every opted-out button was one bad
   hover, and each would have needed its own reset.

   `button:where(:hover)` is 0,0,1, so any class that sets a background now
   wins by construction and a plain <button> still lights up. */
button:where(:hover) { background: var(--accent-hover); }
button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* The opt-in the old default gave away for free. On the centred auth cards the
   button IS the width of the form, because there is one of them and the card is
   already a column. */
.btn-block { width: 100%; margin-top: 1.5rem; }
body.centred button[type="submit"] { width: 100%; margin-top: 1.5rem; }

/* ---------- flashes ---------- */
.flashes { list-style: none; margin: 0 0 1.25rem; padding: 0; }

.flash {
  padding: 0.7rem 0.9rem;
  border-radius: 6px;
  margin-bottom: 0.5rem;
  font-size: 0.92rem;
  border-left: 3px solid var(--muted);
  background: var(--overlay);
}

.flash-danger  { border-left-color: var(--danger);  }
.flash-success { border-left-color: var(--success); }
.flash-warning { border-left-color: var(--warning); }

/* ---------- misc ---------- */
.permissions { margin: 0; padding-left: 1.1rem; }
.permissions li { margin-bottom: 0.25rem; }

code {
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 4px;
  padding: 0.1rem 0.35rem;
  font-size: 0.85rem;
}

/* ---------- main navigation ----------
   Four dropdowns on one row: the band, what is inside it, administration, you.
   Every one of them is <details>/<summary>, so open/close and keyboard access
   come from the browser; only "click elsewhere to close" is scripted, in
   base.html — and because the account menu is the same widget as the rest, that
   one handler covers it too.

   flex-wrap is deliberately NOT set. A bar that wraps to two rows on a narrow
   screen takes twice the height from the page at exactly the size where the
   page has least to spare; the summaries shrink instead — see the band name's
   ellipsis below and the narrow-screen rule under it. */
/* ── IT WRAPS NOW, AND ONLY THE TRAIL EVER DOES ── (2026-09-17)

   The paragraph above was written when this row held two menus and nothing
   else, and it is still right about them: a band badge and a display name that
   dropped onto a second line would take twice the height at exactly the size
   where the page has least to spare, and both hold their line instead.

   What made wrapping necessary is the breadcrumb, which is as long as the trail
   happens to be. Jeff asked for it "on the top line when width is available. If
   width is an issue it can wrap to the next line", and those are two different
   mechanisms rather than one:

     wide    the trail is `flex: 1 1 0` between the two menus. A flex BASIS of
             zero is what keeps it from ever being the item that wraps — it asks
             for nothing and takes what is left — so the bar stays one line and
             a trail longer than the room it got wraps its own crumbs inside it.
     narrow  under 64rem it is given `flex-basis: 100%`, which is a full line of
             its own under the two menus.

   The basis matters more than it looks. With `flex-basis: auto` a long trail's
   hypothetical size is its content, so it would break the line itself — and
   flex fills line two in DOCUMENT order, which would take the account menu down
   with it and leave the bar's right-hand end empty with Sign out somewhere
   nobody looks for it. */
.mainnav {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: 0 0.25rem; width: 100%; min-width: 0;
}

/* Everything from here to the end of the bar is the account menu, pushed to the
   far end. margin-left rather than justify-content, so the groups before it stay
   packed together at the left however many of them there are. */
.navgroup-account { margin-left: auto; }

.navgroup { position: relative; min-width: 0; }

.navgroup > summary {
  list-style: none;               /* Firefox draws a marker without this */
  cursor: pointer;
  padding: 0.35rem 0.7rem;
  border-radius: 6px;
  font-size: 0.9rem;
  color: var(--muted);
  user-select: none;
  /* The bar does not wrap, so on a narrow screen the flex line squeezes these
     until "Jeff Probus" breaks after "Jeff" and the bar grows a second row of
     height anyway — which is the thing not wrapping was meant to avoid. They
     hold their line instead, and the band name is the one thing that gives:
     it is the only summary here whose text has an ellipsis to fall back on. */
  white-space: nowrap;
  flex: none;
}
.navgroup > summary::-webkit-details-marker { display: none; }
.navgroup > summary::after { content: " ▾"; opacity: 0.6; }
.navgroup > summary:hover { color: var(--accent-hover); background: var(--overlay); }
.navgroup[open] > summary { color: var(--text); background: var(--overlay-strong); }
.navgroup-active > summary { color: var(--accent); }

.navmenu {
  position: absolute;
  z-index: 20;
  top: calc(100% + 0.35rem);
  left: 0;
  min-width: 15rem;
  margin: 0;
  padding: 0.35rem;
  list-style: none;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: 0 10px 24px var(--shadow-menu);
}

/* -- ROWS INSIDE THE BAND SWITCHER --
   The submenu rules that used to live here are gone with the dropdowns: the
   sections of the Edit Band screen were a <details> inside a <details>, which
   is two taps to reach a link and a panel that grows twice. They are cards now.

   What replaced them is smaller. The band switcher lists bands, and a band row
   is an avatar beside a name -- the same shape bands/_band_card.html draws, at
   menu scale. */
.navmenu-heading {
  padding: 0.45rem 0.6rem 0.25rem;
  color: var(--muted);
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

.navmenu-rule {
  height: 1px;
  margin: 0.35rem 0.4rem;
  background: var(--border);
}

/* A flex row rather than the block every other .navmenu a is, because this one
   has something to the LEFT of its text. The avatar must not shrink and the
   name must be allowed to, which is the whole of why min-width: 0 is here. */
.navmenu a.navmenu-band {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  min-width: 0;
}
.navmenu-band .avatar { flex: none; }
.navmenu-band-name {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The band you are already in is marked rather than hidden. Removing it would
   make the list jump by one row every time somebody switched, and a switcher
   that does not show what you are on cannot confirm that a switch worked. */
.navmenu-band-current { color: var(--accent); }
.navmenu-tick { margin-left: auto; flex: none; color: var(--accent); }

/* No band open. The badge still has to be a control -- it is the only way to
   choose one -- so it keeps its size and says what it is for. */
.navgroup-band-empty { color: var(--muted); }



.navmenu a {
  display: block;
  padding: 0.5rem 0.6rem;
  border-radius: 6px;
  color: var(--text);
  text-decoration: none;
  font-size: 0.9rem;
}
.navmenu a:hover { background: var(--overlay-strong); color: var(--accent-hover); }
.navmenu a[aria-current="page"] { color: var(--accent); background: var(--tint-accent-soft); }
.navhint { display: block; color: var(--muted); font-size: 0.78rem; margin-top: 0.1rem; }

/* The account menu hangs off the RIGHT edge of the bar. Anchored left, a 15rem
   panel opening from a summary that is already at the far right runs off the
   viewport. */
.navmenu-right { left: auto; right: 0; }

/* A menu entry that acts rather than navigates — "Edit account" opens a dialog.
   A real <button>, styled as the anchors beside it, for the reason .linkish
   exists: an <a href="#"> lies about being navigation and gets followed if the
   script fails to load. */
.navmenu-item {
  display: block; width: 100%;
  margin: 0; padding: 0.5rem 0.6rem;
  background: none; border: 0; border-radius: 6px;
  color: var(--text); font: inherit; font-size: 0.9rem; text-align: left;
  cursor: pointer;
}
.navmenu-item:hover { background: var(--overlay-strong); color: var(--accent-hover); }

/* The theme switch is the last item, separated because it sets a preference
   rather than going somewhere. */
.navmenu-theme {
  margin-top: 0.35rem; padding-top: 0.35rem;
  border-top: 1px solid var(--border);
}
.navmenu-theme .theme-switch { width: 100%; padding: 0.4rem 0.6rem; }

/* ── The band, as the handle of its own menu ─────────────────────────────────
   The summary of the bands group when a band is open. It carries the avatar and
   the name, so the thing that says which band you are working in and the thing
   that changes it are one control — which is what let the separate context strip
   go away. Wider padding and a tint mark it as the anchor of the bar rather than
   another word on it. */
.navgroup-band {
  display: flex; align-items: center; gap: 0.5rem;
  padding: 0.25rem 0.6rem;
  background: var(--tint-accent-faint);
  /* Overrides the flex: none above: this is the summary that shrinks when the
     bar runs out of room, because it is the only one with somewhere to go. */
  flex: 0 1 auto;
  min-width: 0;
}
.navgroup-band .avatar { flex: none; }
.navgroup-band-text {
  display: flex; flex-direction: column; line-height: 1.15; min-width: 0;
}
/* 0.75rem, not 0.62. Ten-pixel type is legible on a desk and guesswork on a
   stand at arm's length, and this is the label that says WHICH BAND the whole
   screen belongs to. Nothing in the Portal sets below 12px now. */
.navgroup-band-label {
  color: var(--muted); font-size: 0.75rem;
  text-transform: uppercase; letter-spacing: 0.08em;
}
/* A long band name shortens rather than pushing the rest of the bar off the
   screen. min-width: 0 on every ancestor up to .mainnav is what makes the
   ellipsis possible at all — a flex item's default min-width is auto, which
   refuses to shrink below its content. */
.navgroup-band-name {
  color: var(--accent); font-weight: 600; font-size: 0.9rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.navgroup-band:hover .navgroup-band-name { color: var(--accent-hover); }


/* === THE CARD MENU ========================================================
   What the five top-bar dropdowns became. main.home draws the band's top level
   and main.menu draws any branch inside it, both through
   templates/main/menu.html and both styled entirely by what follows.

   The grid is the same shape .tile-grid uses for the song library, and for the
   same reason: auto-fill means a phone gets one column and a laptop gets four
   with no breakpoint anybody has to maintain. 15rem rather than the library's
   13rem because a card carries a sentence under its name, and 13rem broke the
   shorter hints across three lines. */
.navcards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
  gap: 0.75rem;
  list-style: none;
  /* ── FOUR ACROSS AT MOST, AND THE BLOCK IS CENTRED ──
     auto-fill fits as many 15rem tracks as the container allows, so on a wide
     monitor the same seven-card menu was laying out five or six across and the
     grid stopped reading as a grid — a row of six 15rem cards is a toolbar.

     Capped by the CONTAINER rather than by writing `repeat(4, ...)`: four fixed
     tracks would still be four on a phone. --menu-max is exactly four tracks
     plus three gaps at 17rem each, which is the widest a track gets before a
     fifth would fit, so the breakpoints stay auto-fill's and nobody maintains
     one. The cards below 4 across still fill the width they are given.

     margin-inline: auto centres what is left over on a monitor wider than the
     cap. Without it a 70rem block sat hard against the left of a 120rem screen
     with half the page empty beside it. */
  --menu-max: 70.25rem;
  max-width: var(--menu-max);
  margin: 0 auto;
  padding: 0;
  /* ── EVERY CARD THE SAME HEIGHT, NOT JUST EVERY CARD IN A ROW ──
     A grid already stretches its items to the tallest in their own row, so a
     four-card row looked right and the row under it was a different height.
     `1fr` on the auto rows makes every row the tallest row, so the whole grid
     is one size — which is what a menu of equal-weight choices should look
     like, and what stops the eye reading a ragged edge as misalignment. */
  grid-auto-rows: 1fr;
}
/* The <li> has to stretch too, or the card sizes to its own content inside a
   row that is already the right height and the rule above does nothing. */
.navcards > li { display: flex; }

.navcard {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  /* Fills the <li>, which fills the grid row. Without it the card is its own
     height inside a stretched cell and grid-auto-rows: 1fr buys nothing. */
  flex: 1;
  /* 88px. The floor is 44px -- the smallest thing a finger hits reliably on
     iOS -- and a menu card can afford twice it, which is the entire argument
     for this control over a dropdown row half that tall. */
  min-height: 5.5rem;
  padding: 0.85rem 0.95rem;
  /* ── TWO DECLARATIONS, AND THE FIRST IS THE FALLBACK ──
     --navcard-fill is a PERCENTAGE set by base.html's head script and by
     atmosphere.js, so a card can be made translucent and let the atmosphere
     behind the menu show through it (V291). 100% is what the Portal has always
     drawn and is the default.

     A percentage rather than a colour because the head script has to set it
     BEFORE first paint, when the stylesheet has not been applied and there is
     no --surface to read — and a card that paints solid and turns translucent a
     moment later is a flicker on every menu load.

     The plain surface is declared FIRST on purpose. A browser without
     color-mix() drops the second declaration and keeps this one, so the setting
     does nothing rather than leaving an unreadable card. Same reason
     portal-webapp.md gives for never letting a custom property be the only
     fallback: this one is on a property that is validated at parse time. */
  background: var(--surface);
  background: color-mix(in srgb, var(--surface) var(--navcard-fill, 100%), transparent);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--text);
  text-decoration: none;
  /* Not a grey rectangle over a rounded corner. */
  -webkit-tap-highlight-color: transparent;
  transition: background 180ms ease-out,
              border-color 180ms ease-out,
              transform 110ms ease-out;
}
.navcard:hover { background: var(--overlay-mid); border-color: var(--muted); }
.navcard:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* -- THE PRESS STATE IS THE ONLY FEEDBACK A TOUCH SCREEN GETS --
   There is no hover on a phone, so without this a tap has nothing to
   acknowledge it until the next page paints -- and on a slow connection that
   reads as a card that did not work, which is how somebody taps twice. */
.navcard:active { transform: scale(0.985); }

/* ── THE ICON ──
   A placeholder set until Jeff's artwork lands; nav.py's ICONS table says
   which shape a card asks for and main/_navicon.html draws it.

   `currentColor`, so one shape serves both themes and picks up the hover
   without a second file existing. Muted at rest and accent on hover, which
   makes the icon part of the card's own state rather than a decoration sitting
   on top of it.

   flex: none, because the card is a column and an SVG in one will otherwise
   stretch to whatever height is going. */
/* The icon and the label share a row; the hint below runs the full width of
   the card rather than indenting into a column beside the icon. Indenting is
   the tidier-looking of the two and costs a 15rem card most of an icon's width
   on every line of a sentence — see the note in main/menu.html.

   align-items: start, not center. A label that wraps to two lines would
   otherwise drag the icon down to sit against the middle of the block; this
   keeps it on the first line where the eye expects it. */
.navcard-head {
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  min-width: 0;
}

.navcard-icon {
  flex: none;
  /* Optically centred on the first line of the label rather than aligned to
     its box: a 22px icon beside a 1rem line sits high without it. */
  margin-top: 0.05rem;
  color: var(--muted);
  transition: color 180ms ease-out;
}
.navcard:hover .navcard-icon { color: var(--accent); }
.navcard-active .navcard-icon { color: var(--accent); }

.navcard-name {
  font-weight: 600;
  font-size: 1rem;
  line-height: 1.3;
  min-width: 0;
  overflow-wrap: anywhere;
}
.navcard-hint {
  color: var(--muted);
  font-size: 0.83rem;
  line-height: 1.4;
  overflow-wrap: anywhere;
}

/* Pushed to the bottom so it lines up across a row whatever the names above it
   did -- the same job .tile-flag does in the song library.

   Its ABSENCE is what says a card arrives somewhere rather than opening
   another grid, which is the question somebody is always asking of a grid. */
.navcard-more {
  display: flex;
  align-items: center;
  gap: 0.3rem;
  margin-top: auto;
  padding-top: 0.5rem;
  color: var(--muted);
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  /* Reserved even when empty — a leaf card draws this row with nothing in it,
     so its name and hint sit at the same height as the branch card beside it.
     An em rather than a rem: it has to match the line this text would occupy
     if there were any, and that follows the font size above. */
  min-height: 1em;
}
.navcard-chev { font-size: 0.95rem; transition: transform 180ms ease-out; }
.navcard:hover .navcard-chev { transform: translateX(2px); color: var(--accent); }

.navcard-active {
  border-color: var(--accent);
  background: var(--tint-accent-soft);
}
.navcard-active .navcard-name { color: var(--accent); }

.navcard .navbadge { vertical-align: middle; }

/* Two action cards under a list of bands, rather than a grid that IS the page.
   Narrower tracks so a pair does not stretch across a wide monitor and read as
   the main event above the bands they sit under. */
.navcards-actions {
  grid-template-columns: repeat(auto-fill, minmax(14rem, 18rem));
  /* NOT centred, unlike the menu's own grid. These two sit under a list of
     bands and have to start where that list starts; centring them would float
     a pair of cards in the middle of a left-aligned page. */
  margin: 1.25rem 0 0;
}

/* Held to the same block as the grid below it, so the title starts where the
   first card starts. A full-width heading over a centred 70rem grid reads as
   two things that were laid out separately. */
.menu-page .menu-head { max-width: 70.25rem; margin-inline: auto; }
.menu-head { margin-bottom: 1rem; }
.menu-head h1 { margin: 0 0 0.2rem; }
.menu-empty-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }

/* -- THE ENTRANCE ---------------------------------------------------------
   Drilling into a branch is a full page load, so this fires on exactly the
   gesture it is describing -- which is why it earns its place where a
   decorative animation would not.

   transform and opacity ONLY. Those two are composited, so the animation runs
   off the main thread and stays smooth on a tablet while the page is still
   settling; animating height or margin would force layout on every frame,
   which is what made the old jQuery-era slide effects janky.

   Capped at four steps. A stagger long enough to watch is a queue, and a band
   opening Lighting at a venue is not watching. */
@keyframes navcard-in {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: none; }
}
.menu-page .navcard { animation: navcard-in 200ms ease-out both; }
.menu-page .navcards > li:nth-child(1) .navcard { animation-delay: 0ms; }
.menu-page .navcards > li:nth-child(2) .navcard { animation-delay: 35ms; }
.menu-page .navcards > li:nth-child(3) .navcard { animation-delay: 65ms; }
.menu-page .navcards > li:nth-child(n+4) .navcard { animation-delay: 90ms; }

/* === THE ATMOSPHERE ========================================================
   The moving background behind the menu cards, chosen per account
   (atmospheres.py, V291) and drawn by static/js/atmosphere.js onto a canvas
   main/menu.html renders. Nothing here draws anything; this is where it sits
   and what it does to the cards on top of it. */

#atmosphere {
  /* Fixed and full bleed, so scrolling a long menu does not drag the weather
     with it — and so the canvas is the size of the window rather than the size
     of the document, which on a twelve-card menu is most of the saving.

     pointer-events: none is the load-bearing line. On a touch screen it is the
     whole difference between an atmosphere and a bug: without it the canvas
     eats every tap meant for a card underneath.

     ── z-index: -1, AND IT HAS TO BE NEGATIVE ──

     Measured 2026-09-22: at z-index 0 this covered the top bar, the breadcrumb
     and the page heading. A POSITIONED element paints after the static content
     of its stacking context, not before it, so "0" puts a full-screen canvas
     over the whole menu — which looks exactly like the page failing to render.

     Negative is the one value that paints between the root background and the
     content. Nothing above it may create a stacking context, or the canvas is
     trapped inside that context and covers whatever is in it; <main> is
     deliberately left alone for that reason, and a rule giving it one was the
     first attempt at this and did not work. */
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: -1;
  pointer-events: none;
  display: block;
}

/* ── LETTING IT THROUGH THE CARDS ──
   Stamped on <html> by the head script, and on the picker's sample menu by
   atmosphere.js, whenever the fill is under 100%. Switched by an attribute
   rather than applied always because a backdrop-filter under an OPAQUE card is
   a compositing pass per frame that changes nothing anybody can see — and on a
   phone that is the difference between a smooth menu and a warm one.

   Not `:root[...]`: the picker scopes the same variables to its own preview, so
   the selector has to match an ordinary element as well as the document. */
[data-cards="glass"] .navcard {
  backdrop-filter: blur(10px) saturate(1.2);
  -webkit-backdrop-filter: blur(10px) saturate(1.2);
}
[data-cards="glass"] .navcard:hover {
  background: var(--overlay-mid);
  background: color-mix(in srgb, var(--surface) var(--navcard-fill-hover, 100%), transparent);
}

/* === CHOOSING ONE =========================================================
   The row in the account dialog's Appearance section, and the picker dialog it
   opens. The picker previews ITSELF — a sample menu with the effect running —
   rather than the page behind, because the account dialog opens from the top
   bar of every screen and only a menu screen has cards to look at. */

.atmo-current {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem 0.75rem;
  margin-top: 0.5rem;
}
.atmo-current-name {
  font-weight: 600;
  /* It names an atmosphere and then its two numbers, which on a phone is
     longer than the button beside it. Allowed to take the line. */
  flex: 1 1 10rem;
  min-width: 0;
}

/* ── THE SAMPLE MENU ──
   Sticky, and that is not decoration. There are seven options below it and the
   dialog scrolls on a phone; a preview that scrolled away would leave somebody
   choosing between names, which is exactly what the picker exists to avoid. */
.atmo-stage {
  position: sticky;
  top: 0;
  z-index: 1;
  isolation: isolate;
  overflow: hidden;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg);
  padding: 0.75rem;
  margin-bottom: 1rem;
  /* ── ROOM AROUND THE CARDS, WHICH IS THE POINT ──
     Sized to the cards first, and it was useless: three cards filled it edge to
     edge and the atmosphere showed in a sliver at the top. At 100% card fill —
     the default, and the case somebody is judging — the gutters are the ONLY
     place the effect is visible, so the preview has to have some. */
  min-height: 11rem;
  display: flex;
  align-items: center;
}
#atmo-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  pointer-events: none;
}
.atmo-cards {
  position: relative;
  width: 100%;
  /* The real grid caps itself at four tracks and centres what is left over.
     This one is three cards in a box a third of that width, so it sets its own
     tracks and drops to one column with the dialog. */
  max-width: none;
  margin: 0;
  gap: 0.5rem;
  grid-template-columns: repeat(3, 1fr);
}
.atmo-cards .navcard {
  min-height: 4.5rem;
  padding: 0.6rem 0.7rem;
  /* A picture of a menu, not a menu: nothing in here is pressable, so nothing
     should suggest it is. */
  cursor: default;
}
.atmo-cards .navcard-name { font-size: 0.88rem; }
.atmo-cards .navcard-hint { font-size: 0.74rem; }
.atmo-cards .navcard-more { display: none; }

.atmo-dials {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 0.75rem 1.5rem;
  margin-bottom: 1rem;
}
.atmo-dial {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.2rem;
}
.atmo-dial label {
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
}
.atmo-dial-value {
  font-family: ui-monospace, "Cascadia Mono", Consolas, monospace;
  font-size: 0.8rem;
  font-variant-numeric: tabular-nums;
  color: var(--text);
}
.atmo-dial-note { flex: 1 1 14rem; margin: 0; }

/* ── THE TWO SIDES ──
   One complete setting per side of the theme switch (V292), so the dialog has
   a tab each. They sit ABOVE the sample rather than beside it: choosing one
   previews that theme on the whole page, and a control that changes the
   colour of everything belongs where it is read before anything else. */
.atmo-tabs {
  display: flex;
  gap: 0.4rem;
  margin-bottom: 0.75rem;
}
/* Styled in full here rather than borrowing a shared class. The first version
   said `class="chip atmo-tab"` — and there IS no .chip in this stylesheet, so
   both tabs fell through to the Portal's default <button>, which is the ACCENT
   button. That made the pressed tab accent-on-accent and its theme name
   invisible. A class name that matches nothing fails silently and looks like a
   styling choice; measured 2026-09-22. */
.atmo-tab {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.05rem;
  line-height: 1.25;
  text-align: left;
  padding: 0.35rem 0.75rem;
  font: inherit;
  background: var(--surface);
  color: var(--muted);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: color 140ms ease-out, border-color 140ms ease-out,
              background 140ms ease-out;
}
.atmo-tab:hover { color: var(--text); border-color: var(--muted); }
.atmo-tab:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.atmo-tab[aria-pressed="true"] {
  color: var(--text);
  border-color: var(--accent);
  background: var(--tint-accent-soft);
}
.atmo-tab-side {
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}
/* The theme's own name, filled in by atmosphere_picker.js from theme.js. It is
   the half somebody recognises — "One house" rather than "dark" — so it takes
   the weight, and the side above it is the label. */
.atmo-tab-theme { font-size: 0.86rem; font-weight: 600; }
.atmo-tab[aria-pressed="true"] .atmo-tab-theme { color: var(--accent); }

/* ── WHICH COLOURS ──
   A swatch per role, filled from the same custom property the canvas reads, so
   the chip and the effect cannot disagree about what "Highlight" means in this
   theme. The swatch is the point: the NAME is a role and cannot say what the
   colour is, and it is a different colour in every one of the nine themes. */
.atmo-colours { margin-bottom: 0.75rem; }
.atmo-colour-row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 0.75rem;
}
.atmo-colour {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.25rem 0.5rem 0.25rem 0.35rem;
  border: 1px solid var(--border);
  border-radius: 999px;
  cursor: pointer;
  font-size: 0.85rem;
}
.atmo-colour:hover { border-color: var(--muted); }
.atmo-colour:has(input:checked) {
  border-color: var(--accent);
  background: var(--tint-accent-soft);
}
.atmo-colour-chip {
  width: 0.95rem;
  height: 0.95rem;
  border-radius: 50%;
  flex: none;
  /* A ring, because --warning on a pale card and --special on a dark one both
     come close enough to the ground to vanish without one. */
  box-shadow: inset 0 0 0 1px var(--overlay-bold);
}
.atmo-colour-name { white-space: nowrap; }

/* An atmosphere offered by atmospheres.py with no effect behind it in
   atmosphere.js. It cannot happen to a person — the two files ship together —
   so this is what a developer who added one and forgot the other sees, beside
   the named warning atmosphere_picker.js puts in the console. */
.theme-option.is-unavailable {
  opacity: 0.45;
  cursor: not-allowed;
}

@media (max-width: 640px) {
  /* Three sample cards at phone width are three slivers. One is enough to show
     what the fill does to a card. */
  .atmo-cards { grid-template-columns: 1fr; }
  .atmo-cards > li:nth-child(n+2) { display: none; }
}

/* NO prefers-reduced-motion RULE HERE, deliberately. atmosphere.js already
   paints one settled frame and stops — it runs the trail effects forward 150
   steps first, so a still page is composed rather than nearly empty. Hiding the
   canvas in CSS instead would throw that picture away along with the motion,
   which is more than the preference asks for. */

/* === THE TRAIL ============================================================
   On every signed-in page, not only the menu ones. With the section dropdowns
   gone this is how somebody gets back UP, and a content screen three levels in
   would otherwise have no way out but the browser's Back button.

   It always starts with Menu, and that first crumb is the guarantee: no screen
   in the Portal is a dead end. The rest comes from nav.trail_for, which works
   it out from the route -- so a page never declares where it lives, and cannot
   disagree with the menu about it. */
.navtrail {
  /* In the top bar since 2026-09-17, between the band badge and the account
     menu -- so it has no border and no background of its own any more; the bar
     is its surface. See .mainnav for how it wraps.

     A basis of ZERO, not a width: it asks for nothing, takes whatever the two
     menus leave, and so is never the item that breaks the line. See .mainnav
     for what that is protecting. */
  flex: 1 1 0;
  min-width: 0;
  padding: 0 0.25rem;
}

/* Under this the trail gets a line of its own beneath the two menus, which is
   the "it can wrap to the next line" half. A number rather than something
   measured, because the trail is as long as somebody's band name and section
   happen to be and no rule can know that in advance -- and the wide behaviour
   above degrades gracefully either side of it, since a trail with too little
   room wraps its own crumbs rather than overflowing. */
@media (max-width: 64rem) {
  .navtrail { flex-basis: 100%; padding: 0 0.25rem 0.1rem; }
  /* And THIS is what keeps the bar two lines rather than three. Flex fills a
     line in order-modified order, so without it the account menu -- which comes
     after the trail in the document -- is pushed onto a third line of its own
     and the band badge sits alone on the first. */
  .navgroup-account { order: 1; }
  .navtrail { order: 2; }
}
.navtrail ol {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0 0.15rem;
  list-style: none;
  margin: 0;
  padding: 0;
  font-size: 0.82rem;
}
.navtrail li { display: flex; align-items: center; gap: 0.15rem; min-width: 0; }

.navtrail a {
  display: inline-block;
  /* A real target rather than the decoration a breadcrumb usually is -- going
     up a level is one of the two things this rail exists for. It was 0.55rem
     top and bottom while the rail was a strip of its own; in the bar the row's
     own height already covers that, and the taller padding simply made the bar
     taller. */
  padding: 0.3rem 0.3rem;
  color: var(--muted);
  text-decoration: none;
  border-radius: 4px;
  -webkit-tap-highlight-color: transparent;
}
.navtrail a:hover { color: var(--accent-hover); background: var(--overlay); }
.navtrail a:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.navtrail-home { margin-right: 0.15rem; }
.navtrail-sep { color: var(--muted); opacity: 0.55; }
.navtrail-here {
  padding: 0.3rem 0.3rem;
  color: var(--text);
  font-weight: 600;
}

@media (prefers-reduced-motion: reduce) {
  .menu-page .navcard { animation: none; }
  .navcard, .navcard-chev { transition-duration: 1ms; }
}

@media (max-width: 640px) {
  /* "Working in" is the first thing to go. The avatar and the name already say
     which band, and on a phone the bar needs the two lines back. */
  .navgroup-band-label { display: none; }
  .topbar { padding: 0.5rem 0.6rem; }
  .navgroup > summary { padding: 0.35rem 0.45rem; }

  /* -- THE HAMBURGER IS GONE, AND SO IS THE REASON FOR IT --
     _navmore.html existed because five top-level controls stopped fitting here
     -- measured on an iPhone at 390px, the bar overflowed by 27px and what
     fell off the end was the ACCOUNT menu, so Sign out was unreachable.

     There are two controls now. They fit, with room for a display name far
     longer than the one that took that overflow to 55px, so the panel, its
     sibling-combinator machinery and the three lines it cost the outside-click
     handler have all been deleted rather than left switched off. */

  /* A dropdown spans the bar rather than hanging off its own summary.
     Measured at 375px: a 15rem panel anchored under a summary near the right
     end drags the DOCUMENT's scroll width out with it, so opening a menu made
     the whole page scroll sideways -- every screen behind it included.

     The mechanism is the containing block, not a width. Dropping .navgroup to
     position: static hands the absolute positioning up to .mainnav, so left: 0
     and right: 0 mean the ends of the BAR and the panel cannot be anywhere
     else. No viewport arithmetic and no assumed bar height. */
  .mainnav { position: relative; }
  .navgroup { position: static; }
  .navmenu {
    left: 0; right: 0; min-width: 0;
    /* A phone in landscape has very little height, and a member of six bands
       fills it. Capped against the viewport rather than a guessed number of
       rows, so it is right on every device. */
    max-height: calc(100vh - 4rem);
    overflow-y: auto;
  }

  /* Every row in a menu is a real target on a phone. */
  .navmenu a, .navmenu .navmenu-item { min-height: 2.75rem; }

  /* On a phone the bar has wrapped for certain, so the trail is its own line
     under the two menus and takes the bar's own gutter. */
  .navtrail { flex-basis: 100%; padding: 0 0.1rem 0.15rem; }
  .navtrail ol { font-size: 0.78rem; }

  /* One column, and the cards get the width back. */
  .navcards { grid-template-columns: 1fr; gap: 0.6rem; }
}

/* ---------- inline create form ---------- */
.inline-form {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
  align-items: flex-start;
  padding: 1rem;
  margin-bottom: 0.75rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.field { display: flex; flex-direction: column; min-width: 10rem; flex: 1 1 10rem; }
.field-wide { flex: 2 1 18rem; }
.field label { margin: 0 0 0.35rem; }

.inline-form input {
  width: 100%;
  padding: 0.5rem 0.6rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text);
  font-size: 0.95rem;
}

.inline-submit { width: auto; margin-top: 1.6rem; padding: 0.55rem 1.1rem; }

.field-note { color: var(--muted); font-size: 0.78rem; margin: 0.3rem 0 0; }
.field-note.warn { color: var(--warning); }

/* Keep number inputs from being narrower than their placeholder. */
.inline-form input[type="number"] { min-width: 7rem; }

/* ---------- data tables ---------- */
.table-scroll { overflow-x: auto; }

.datatable { width: 100%; border-collapse: collapse; font-size: 0.92rem; }
.datatable th {
  text-align: left;
  padding: 0.6rem 0.7rem;
  color: var(--muted);
  font-size: 0.78rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  border-bottom: 1px solid var(--border);
  white-space: nowrap;
}
.datatable td { padding: 0.7rem; border-bottom: 1px solid var(--border); vertical-align: top; }
.datatable tbody tr:hover { background: var(--overlay-faint); }

/* A withdrawn or spent code stays legible but visibly retired — it is still the
   record of a campaign that ran. */
.code-row.status-inactive .cell-code,
.code-row.status-expired .cell-code,
.code-row.status-exhausted .cell-code { opacity: 0.6; }

.code-value { font-size: 0.95rem; letter-spacing: 0.03em; }
.cell-code { white-space: nowrap; }
.cell-uses { white-space: nowrap; }
.cell-expiry { white-space: nowrap; }
.small { font-size: 0.78rem; }
.cell-expiry .muted { display: block; }

/* Briefly mark the row that just changed, so an inline write is visible without
   moving the page. */
@keyframes rowflash { from { background: var(--tint-accent-strong); } to { background: transparent; } }
.row-flash { animation: rowflash 1.2s ease-out; }

/* ---------- badges ---------- */
.badge {
  display: inline-block;
  padding: 0.12rem 0.5rem;
  border-radius: 999px;
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  border: 1px solid;
  /* 0.75rem floor — a status somebody reads across a table of forty accounts. */
}
.badge-active    { color: var(--success); border-color: var(--success); background: var(--tint-success); }
.badge-inactive  { color: var(--muted);   border-color: var(--border);  background: var(--overlay); }
.badge-expired   { color: var(--warning); border-color: var(--warning); background: var(--tint-warning); }
.badge-exhausted { color: var(--warning); border-color: var(--warning); background: var(--tint-warning); }
.badge-warning   { color: var(--danger);  border-color: var(--danger);  background: var(--tint-danger); }

/* ---------- link-styled buttons ----------
   Real <button> elements, styled flat. An <a href="#"> would be reachable by
   keyboard but lies about being navigation, and gets followed if the JS fails
   to load.

   An <a> DOES wear this class, in the other direction: a control that is real
   navigation — Cancel back to the list, the guide's performance view — is an
   anchor and takes the class to sit level with the buttons beside it. Which is
   why text-decoration is stated here. A button carries no underline to begin
   with, so the declaration exists entirely for those anchors: without it they
   arrive underlined, stand 3px taller than every sibling on the row, and turn
   the hover rule below into a no-op because the underline it adds is already
   there. */
/* ── The hit area is not the mark ──
   These are the verbs on every list screen — Edit, Remove, Note, Add, Retire,
   and the reorder arrows — and they measured 18px tall, the arrows 16px wide.
   On the set list that is five per row, fifteen rows, on a screen somebody uses
   standing up.

   So the TARGET grows and the mark does not: inline-flex with a min-height, and
   a negative vertical margin that gives the padding back to the layout. The
   text sits exactly where it did and the row keeps its height; what changes is
   how much of the screen answers to a finger.

   32px with a mouse, 44px with a finger. A pointer query rather than a width
   one, because the thing that decides is what is doing the pointing — a 44px
   row on a dense desktop table is wasted height, and a phone in landscape is
   still a phone. */
.linkish {
  display: inline-flex;
  align-items: center;
  width: auto;
  min-height: 32px;
  margin: -0.4rem 0;
  padding: 0.1rem 0.35rem;
  background: none;
  border: 0;
  color: var(--accent);
  font-size: 0.85rem;
  font-weight: 500;
  text-decoration: none;
  cursor: pointer;
}
@media (pointer: coarse) {
  .linkish { min-height: 44px; padding-left: 0.55rem; padding-right: 0.55rem; }
}
.linkish:hover { background: none; color: var(--accent-hover); text-decoration: underline; }
.linkish:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
.linkish:disabled { color: var(--muted); cursor: default; text-decoration: none; }
.linkish.danger { color: var(--danger); }

/* ── A destructive verb does not sit in the queue with the safe ones ──
   The dialogs already solve this: .dialog-actions-danger pushes Remove to the
   far left, away from Save, because a Remove next to Save is a Remove somebody
   eventually hits on the way to saving. The same is true of a row of links on a
   page, where "Rename or add notes" and "Remove this setlist" were the same
   size, the same weight and the same type, separated only by hue — and hue is
   the one signal that fails for a colour-blind reader.

   A gap and a rule, rather than a different shape: these are still quiet links
   and should stay quiet. What changes is that the hand has to travel. */
.linkish.danger:not(:only-child) {
  margin-left: 0.6rem;
  padding-left: 0.9rem;
  border-left: 1px solid var(--border);
  border-radius: 0;
}
.cell-actions { white-space: nowrap; }
.cell-actions .linkish + .linkish { margin-left: 0.5rem; }
.cell-actions .linkish.danger:not(:only-child) { margin-left: 0.6rem; }

/* ---------- edit dialog ---------- */
dialog {
  width: min(30rem, calc(100vw - 2rem));
  padding: 1.75rem;
  background: var(--surface);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
dialog::backdrop { background: var(--scrim); }
dialog h2 { margin-top: 0; color: var(--text); font-size: 1.15rem; }
dialog input {
  width: 100%;
  padding: 0.55rem 0.65rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text);
  font-size: 0.95rem;
}
/* WRAPS, and the buttons inside it do not. Measured on a 375px phone with the
   member dialog's worst case — Remove from the band, Revoke admin, Close: at
   279px of row against 294px of buttons, a nowrap row squeezed them until each
   label broke over three lines and the first overflowed the dialog's left edge.
   Letting the ROW wrap and the labels hold their line is the right way round: a
   button that has to go to a second row is tidy, a button whose words are
   stacked three high is not. */
.dialog-actions {
  display: flex; flex-wrap: wrap; gap: 0.75rem;
  justify-content: flex-end; align-items: center;
  margin-top: 1.5rem;
}
/* The destructive control in a dialog goes to the far LEFT, away from Save.
   Cancel and Save are the ordinary way out and muscle memory goes to that end;
   a Remove next to Save is a Remove somebody eventually hits on the way to
   saving. margin-right: auto on the first one is what splits the row —
   whichever of Remove and Restore is showing, only one ever is. */
/* Scoped to .dialog-actions, and that is not decoration: `.dialog-actions
   button` below sets `margin: 0` as a shorthand, and being a later rule of
   equal-or-higher specificity it silently won. The destructive button sat with
   the others at the right-hand end for as long as this rule was written
   unscoped, on all five dialogs, and nothing looked broken enough to notice. */
.dialog-actions .dialog-actions-danger { margin-right: auto; }
/* On a phone the row wraps, and an auto margin fights that — it holds the gap
   open on the first line and pushes the rest onto a second one that did not
   need to exist. Below the breakpoint the buttons simply pack to the right and
   wrap when they must. */
@media (max-width: 640px) {
  .dialog-actions .dialog-actions-danger { margin-right: 0; }
}

/* The body of a detail dialog — the card's own .block-card-head and
   .block-card-facts, cloned in by bands.js. It gets the card's inner spacing
   back, because the clone arrives without the card's padding: the padding lives
   on .block-card-open, which is not cloned. */
.detail-body {
  display: flex; flex-direction: column; gap: 0.7rem;
  padding: 0.9rem 1rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
/* Roomier than on a card. A dialog is being read rather than scanned, so the
   facts get a column each instead of being packed into a wrapping row. */
.detail-body .block-card-facts { gap: 0.7rem 1.5rem; margin-top: 0; }
.detail-body .block-card-name { font-size: 1.05rem; }
.dialog-actions button { width: auto; margin: 0; padding: 0.55rem 1.1rem; white-space: nowrap; }
button.secondary { background: transparent; color: var(--muted); border: 1px solid var(--border); }
button.secondary:hover { background: var(--overlay-mid); color: var(--text); }

/* ---------- the "are you sure" before a retire ----------
   Built by PCW.confirmRetire in inline_crud.js. Retire is the button that
   ends the dialog here, so it takes the danger colour rather than being pushed
   to the far left the way an edit dialog's Remove is. --accent-ink flips with
   the theme, which keeps the label readable on both reds. */
.retire-uses-head { margin: 0.25rem 0 0; }
.retire-uses-list { margin: 0.35rem 0 0.75rem; padding-left: 1.2rem; }
.retire-uses-list li { margin: 0.25rem 0; overflow-wrap: anywhere; }
.retire-note { color: var(--muted); margin: 0.5rem 0 0; }
.retire-blocked { color: var(--danger); font-weight: 600; margin: 0.75rem 0 0; }
.dialog-actions button.retire-go { background: var(--danger); color: var(--accent-ink); }
.dialog-actions button.retire-go:where(:hover) { filter: brightness(1.08); }
.dialog-actions button.retire-go:disabled { opacity: 0.55; cursor: default; filter: none; }

/* Band Admin › Retired: who retired a row and when, under its name — at the
   card's secondary size, because it is the fact beside the thing, not the
   thing. */
.retired-when { font-size: 0.85rem; margin: 0.4rem 0 0; }

/* ---------- misc ---------- */
.link-row { margin: 1.25rem 0 0; font-size: 0.9rem; color: var(--muted); text-align: center; }
.link-row a { color: var(--accent); }
.empty-note { padding: 1.25rem 0; }
/* ---------- the live flash region ----------
   Messages raised by inline AJAX writes, pinned to the BOTTOM of the viewport.

   It used to be the first thing in <main>, above the card, which made every
   "Band details saved." a layout change: the page was pushed down by the height
   of the message and jumped back up six seconds later when it was removed. The
   form somebody was still looking at moved under them at the exact moment they
   were told nothing had gone wrong.

   position: fixed takes it out of flow entirely, so nothing moves. At the
   bottom rather than the top because the top is where the thing just saved
   usually is, and because a message that appears near the edge somebody's
   pointer is already leaving is the least intrusive place for it.

   The flashes Flask renders server-side are deliberately NOT this: they arrive
   with a fresh page rather than appearing under a settled one, so they stay in
   <main> where they push nothing. */
.flashes-live {
  position: fixed;
  left: 50%;
  bottom: 1rem;
  transform: translateX(-50%);
  z-index: 60;
  width: min(28rem, calc(100vw - 2rem));
  margin: 0;
  /* Click-through. A toast sitting over the bottom of the page must not be able
     to swallow a press on whatever is underneath it — the individual messages
     turn pointer events back on so text in one can still be selected. */
  pointer-events: none;
}
.flashes-live .flash {
  pointer-events: auto;
  margin: 0 0 0.5rem;
  /* Opaque, not the translucent --overlay the in-flow flashes use: this one
     floats over content rather than sitting on the page's own ground, and a
     see-through toast with a table behind it is unreadable. */
  background: var(--surface);
  /* Three sides, named individually. The `border` shorthand would reset
     border-left-color, and this selector outranks .flash-danger / .flash-success
     — so every toast would come out muted-grey down the edge that is the only
     thing distinguishing a failure from a success. */
  border-top: 1px solid var(--border);
  border-right: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
  box-shadow: 0 6px 20px var(--shadow-menu);
  animation: flash-rise 0.18s ease-out;
}
@keyframes flash-rise {
  from { opacity: 0; transform: translateY(0.5rem); }
  to   { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
  .flashes-live .flash { animation: none; }
}
#live-flashes:empty { display: none; }

/* ==========================================================================
   BANDS
   ========================================================================== */

/* ---------- avatars ----------
   One set of classes for every avatar in the Portal, because
   .claude/rules/portal-webapp.md requires one wherever a band member appears —
   tables, checkbox grids, lists — and a rule that broad only holds if there is a
   single thing to reach for.

   The circle is fixed-size with overflow hidden and the <img> is positioned
   inside it, so the crop from the adjust control (scale + offset) can be applied
   as a transform without touching the stored bytes. `position: relative` here is
   what the absolutely positioned, cropped <img> resolves against — without it a
   cropped avatar escapes its circle and lands in the top-left of the page. */
.avatar {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  overflow: hidden;
  border-radius: 50%;
  background: var(--bg);
  border: 1px solid var(--border);
  color: var(--muted);
  font-weight: 600;
  text-transform: uppercase;
  user-select: none;
  vertical-align: middle;
}

.avatar img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.avatar-placeholder { background: var(--tint-accent-mid); color: var(--accent); }

.avatar-sm { width: 1.6rem;  height: 1.6rem;  font-size: 0.7rem;  }
.avatar-md { width: 2.4rem;  height: 2.4rem;  font-size: 0.9rem;  }
.avatar-lg { width: 3.5rem;  height: 3.5rem;  font-size: 1.25rem; }
.avatar-xl { width: 5rem;    height: 5rem;    font-size: 1.75rem; }

/* Aliases for the class names .claude/rules/portal-webapp.md names directly, so
   markup written against the rule as worded keeps working. */
.member-avatar,
.member-avatar-placeholder {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.4rem;
  height: 2.4rem;
  overflow: hidden;
  border-radius: 50%;
  border: 1px solid var(--border);
  font-size: 0.9rem;
  font-weight: 600;
}
.member-avatar { background: var(--bg); }
.member-avatar-placeholder { background: var(--tint-accent-mid); color: var(--accent); }

/* ---------- a member, with their name ---------- */
.member-chip { display: flex; align-items: center; gap: 0.6rem; }
.member-chip-text { display: flex; flex-direction: column; line-height: 1.25; }
.member-chip-name { color: var(--text); }
.member-chip-sub { color: var(--muted); font-size: 0.78rem; }

/* ---------- the bands list ---------- */
.band-grid {
  list-style: none;
  margin: 0 0 1.5rem;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(18rem, 1fr));
  gap: 0.85rem;
}

.band-card {
  position: relative;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

.band-card-link {
  display: flex;
  gap: 0.85rem;
  align-items: flex-start;
  padding: 1rem;
  text-decoration: none;
  color: var(--text);
}
/* The hover is on .band-card, which is the element carrying the border —
   see the shared rule. */

.band-card-body { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
.band-card-name { font-weight: 600; }
.band-card-desc {
  color: var(--muted);
  font-size: 0.85rem;
  /* Two lines then ellipsis: a description is up to 500 characters and a card
     that grows to fit one breaks the grid alignment for every other band. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.band-card-meta { color: var(--muted); font-size: 0.78rem; margin-top: 0.2rem; }

.band-card-badge {
  display: block;
  padding: 0.4rem 1rem;
  border-top: 1px solid var(--border);
  color: var(--accent);
  font-size: 0.8rem;
  text-decoration: none;
}
.band-card-badge:hover { background: var(--tint-accent-faint); }

/* ── THE STORAGE METER ───────────────────────────────────────────────────────
   One definition for both places it appears — the band's own Details tab and
   the platform-administration card — because they show the same figure and two
   stylesheets for one meter is two meters that eventually disagree about what
   full looks like. templates/bands/_storage_meter.html is the markup.

   The bar is drawn from theme tokens rather than fixed colours so it holds up in
   both themes, and the "over" state uses the warning tint rather than danger:
   past the line is worth NOTICING and is not a failure — nothing is enforced. */
.storage-meter {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  max-width: 30rem;
}
.storage-meter-track {
  display: block;
  height: 0.5rem;
  border-radius: 999px;
  background: var(--overlay-bold);
  overflow: hidden;
}
.storage-meter-fill {
  display: block;
  height: 100%;
  border-radius: inherit;
  background: var(--accent);
  /* A meter that jumps between page loads reads as a glitch; one that grows
     into place reads as a measurement. Cheap, and only ever runs once. */
  transition: width 400ms ease-out;
}
.storage-meter-text { font-size: 0.82rem; }
.storage-meter.is-over .storage-meter-fill { background: var(--danger); }
.storage-meter.is-over .storage-meter-text { color: var(--danger); }

/* On a card: the bar sits under the meta line and the figure beside it, because
   a card is scanned in a column and a second stacked block would push the join
   badge off the bottom of a short tile. */
.storage-meter-compact { flex-direction: row; align-items: center; gap: 0.5rem; }
.storage-meter-compact .storage-meter-track { flex: 1; height: 0.35rem; }
.storage-meter-compact .storage-meter-text { font-size: 0.75rem; white-space: nowrap; }

.band-card-storage {
  display: block;
  padding: 0.4rem 1rem 0.6rem;
  border-top: 1px solid var(--border);
}
.band-card-orphan { display: block; margin-top: 0.25rem; }

/* ---------- requests you have raised ---------- */
.request-list { list-style: none; margin: 0; padding: 0; }
.request-item {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.7rem 0;
  border-bottom: 1px solid var(--border);
}
.request-item-text { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.request-item-name { color: var(--text); }
.badge-pending { color: var(--warning); border-color: var(--warning); background: var(--tint-warning); }

.crumb { margin: 0 0 0.75rem; font-size: 0.85rem; }
.crumb a { color: var(--muted); text-decoration: none; }
.crumb a:hover { color: var(--accent-hover); }

/* ---------- tabs ----------
   <button> elements, not links: they change what is visible on this page rather
   than navigating, and an <a href="#"> would be followed if the script failed to
   load. */
.tab-nav {
  display: flex;
  flex-wrap: wrap;
  gap: 0.25rem;
  border-bottom: 2px solid var(--border);
  margin: 1.25rem 0 1.5rem;
}

.tab-btn {
  width: auto;
  margin: 0;
  padding: 0.6rem 1rem;
  background: none;
  border: 0;
  border-bottom: 2px solid transparent;
  margin-bottom: -2px;
  color: var(--muted);
  font-size: 0.92rem;
  font-weight: 500;
  border-radius: 0;
  cursor: pointer;
}
.tab-btn:hover { background: var(--overlay); color: var(--text); }
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); background: none; }

.tab-count {
  display: inline-block;
  margin-left: 0.35rem;
  padding: 0.05rem 0.4rem;
  border-radius: 999px;
  background: var(--overlay-bold);
  font-size: 0.75rem;
}
.tab-count-alert { background: var(--tint-warning-strong); color: var(--warning); }

.tab-panel { display: none; }
.tab-panel.active { display: block; }

/* The heading a section carries now that the band page has no tab strip.
   The tab WAS the label — you knew which section you were in by which button
   was lit — so removing the strip without this would land somebody on a page of
   prose with nothing saying what they were looking at.

   The song editor keeps its strip and its panels have no heading, which is why
   this is its own class rather than a rule on .tab-panel h2. */
.panel-head {
  margin: 0 0 0.75rem;
  padding-bottom: 0.5rem;
  border-bottom: 1px solid var(--border);
  font-size: 1.1rem;
}
/* Same shape as .tab-count, which is still in use on the song editor's strip —
   the two are the same object in two places and should look it. */
/* --overlay-bold under --muted composites to #615b51 on #d5d2cc in Daylight,
   which is 4.45 against a 4.5 requirement — the only pair in the Portal that
   misses, and it misses because neither rule mentions the other. Lightening the
   wash rather than darkening the ink is the smaller move: the count is a quiet
   thing beside a heading and should stay quiet, and --overlay-mid puts it at
   5.16 on the page and 5.9 on a card. */
.panel-count {
  display: inline-block;
  margin-left: 0.35rem;
  padding: 0.05rem 0.45rem;
  border-radius: 999px;
  background: var(--overlay-mid);
  font-size: 0.78rem;
  font-weight: 500;
  vertical-align: middle;
}
.panel-count-alert { background: var(--tint-warning-strong); color: var(--warning); }

/* ---------- stacked forms on the band page ---------- */
.stacked-form { max-width: 32rem; }
.stacked-form .inline-submit { margin-top: 1.25rem; }
.form-actions { display: flex; gap: 0.75rem; align-items: center; margin-top: 1.25rem; }
.form-actions button { margin-top: 0; width: auto; padding: 0.55rem 1.1rem; }

.narrow-card { max-width: 34rem; margin: 0 auto; }

/* ---------- the avatar crop control ---------- */
.avatar-adjust {
  margin-top: 1rem;
  padding: 0.85rem 1rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.avatar-adjust label { margin: 0.5rem 0 0.2rem; }
.avatar-adjust label:first-child { margin-top: 0; }
.avatar-adjust input[type="range"] { width: 100%; accent-color: var(--accent); }
.avatar-adjust .field-note { margin: 0.75rem 0 0; }
/* ── WITH DIALS ── (2026-09-15)
   rotary.js marks a converted slider's parent `.rotary-host`. A slider filled
   the row; a dial does not, so the three sit side by side, each under its
   label. The hidden sliders are positioned out of the grid and take no cell.
   :not([hidden]) because display: grid would otherwise un-hide a panel the
   page hides until there is a picture to adjust. */
.avatar-adjust.rotary-host:not([hidden]),
.image-crop.rotary-host:not([hidden]) {
  display: grid; grid-template-columns: repeat(3, minmax(0, 1fr));
  grid-template-rows: auto auto; grid-auto-flow: column;
  justify-items: center; align-items: center; gap: 0.25rem 0.6rem;
}
.avatar-adjust.rotary-host label,
.image-crop.rotary-host label { margin: 0; text-align: center; font-size: 0.85rem; }
.avatar-adjust.rotary-host .field-note { grid-column: 1 / -1; grid-row: 3; justify-self: stretch; }

/* ── The Avatar tab ──────────────────────────────────────────────────────────
   The picture on the left, the controls on the right, and on a narrow screen
   the picture on top. Not a .stacked-form: the whole point of the tab is that
   the thing being adjusted and the sliders adjusting it are visible together,
   which a single 32rem column cannot do. */
.avatar-editor {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 2rem;
  margin: 0;
}
.avatar-editor-controls { flex: 1 1 20rem; min-width: 0; max-width: 32rem; }

/* Sticky so the crop stays in view while the sliders are being worked, on a
   viewport short enough that they scroll apart. It is the only thing on the tab
   that has to be looked at continuously. */
.avatar-preview { flex: none; position: sticky; top: 1rem; }

/* Its own size, not a sixth entry in the sm/md/lg/xl scale. Every avatar in that
   scale is a label beside a name; this one is the subject of the screen, and
   adding a step to the shared scale would make it tempting to use as a label
   somewhere it would dwarf the text next to it. */
.avatar-preview .avatar { width: 12rem; height: 12rem; font-size: 4rem; }

input[type="file"] {
  width: 100%;
  padding: 0.5rem;
  background: var(--bg);
  border: 1px dashed var(--border);
  border-radius: 6px;
  color: var(--muted);
  font-size: 0.9rem;
}

/* ---------- role chips ---------- */
.role-chip {
  display: inline-block;
  margin: 0.1rem 0.25rem 0.1rem 0;
  padding: 0.1rem 0.5rem;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: var(--overlay);
  color: var(--text);
  font-size: 0.75rem;
}

.role-name { display: block; color: var(--text); }
.role-row.role-inactive { opacity: 0.55; }
.cell-role, .cell-perms { min-width: 12rem; }
.cell-joined, .cell-created { white-space: nowrap; }
.cell-created .muted { display: block; }

/* ---------- checkbox grids in the role editor ---------- */
.check-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: 0.35rem;
  margin: 0.5rem 0 0.5rem;
  max-height: 14rem;
  overflow-y: auto;
  padding: 0.5rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 6px;
}

.check-item {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  margin: 0;
  padding: 0.35rem 0.4rem;
  border-radius: 6px;
  color: var(--text);
  font-size: 0.9rem;
  cursor: pointer;
}
.check-item:hover { background: var(--overlay-mid); }
/* Sizing and accent-color now come from the base rule; only the margin reset
   is local. Left as `width: auto` these three fought it and stayed 13px. */
.check-item input[type="checkbox"] { margin: 0; }
.check-item-text { display: flex; flex-direction: column; line-height: 1.25; }
.check-item-label { color: var(--text); }
.check-item-sub { color: var(--muted); font-size: 0.72rem; }

dialog h3 { margin: 1.25rem 0 0.25rem; font-size: 0.85rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.04em; }

/* The role dialog carries two scrolling grids and needs more room than the
   signup-code one. */
#role-dialog { width: min(38rem, calc(100vw - 2rem)); }

/* ---------- the join confirmation ---------- */
.join-band-head { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.25rem; }
.join-band-head h1 { margin: 0; }
.join-band-head .sub { margin: 0.2rem 0 0.1rem; }

/* ---------- responsive ----------
   The band page is opened on a phone at a rehearsal at least as often as on a
   desktop, and the members table is the reason: it is what somebody checks when
   a name comes up. */
@media (max-width: 40rem) {
  .tab-nav { gap: 0; }
  .tab-btn { padding: 0.55rem 0.7rem; font-size: 0.85rem; }
  .band-grid { grid-template-columns: 1fr; }
}

/* ---------- banners ----------
   A standing notice at the top of a page, distinct from a flash: a flash is
   something that just happened and fades, a banner is a condition that holds for
   as long as the page is open. The platform-administration one must not be
   dismissible — somebody editing a band they are not in should be told so the
   whole time, not once. */
.banner {
  margin: 0 0 1.25rem;
  padding: 0.8rem 1rem;
  border-radius: var(--radius);
  border-left: 3px solid var(--muted);
  background: var(--overlay);
  font-size: 0.9rem;
  line-height: 1.45;
}
.banner-platform { border-left-color: var(--warning); background: var(--tint-warning-soft); }
.banner code { font-size: 0.8rem; }

/* The visitor's banner (pedalboard pass 11). Stuck to the top of the window,
   because whether their work can be saved is the one thing they must not
   scroll away from. Closed registration is louder than open: that one is a
   warning, and it is there so nobody builds a board they cannot keep. */
.guest-banner {
  position: sticky; top: 0; z-index: 50;
  margin: 0; border-radius: 0;
  border-left-width: 0; border-bottom: 3px solid var(--accent);
  background: var(--surface);
  display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1.25rem;
}
.guest-banner-closed {
  border-bottom-color: var(--warning);
  background: var(--surface);
  box-shadow: inset 0 0 0 100vmax var(--tint-warning-soft);
}
.guest-banner-closed strong { color: var(--warning); }
.guest-banner-text { margin: 0; flex: 1 1 22rem; }
.guest-banner-actions {
  margin: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.75rem;
}
.guest-banner-nav { white-space: nowrap; }

/* ---------- pager ---------- */
.pager {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 1rem;
  margin-top: 1.25rem;
  padding-top: 1rem;
  border-top: 1px solid var(--border);
}
.pager button { width: auto; margin: 0; padding: 0.45rem 0.9rem; font-size: 0.88rem; }
.pager button:disabled { opacity: 0.4; cursor: default; }
.pager button:disabled:hover { background: transparent; color: var(--muted); }
.pager-state { color: var(--muted); font-size: 0.85rem; }

/* A deactivated band still appears on the support list — that is usually what
   the ticket is about — but should not read as a live one. */
.band-card-inactive { opacity: 0.65; border-style: dashed; }
.band-card-name .badge { margin-left: 0.4rem; vertical-align: middle; }

#band-count { margin: 0.25rem 0 0.9rem; }

/* ---------- user administration ---------- */
.user-row.user-inactive .cell-user { opacity: 0.6; }
.cell-user { min-width: 16rem; }
.cell-status .badge + .badge { margin-left: 0.3rem; }

/* The generated temporary password, shown once. Large and monospaced because it
   is read aloud down a phone, which is the only way it reaches its owner. */
.generated-row {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-top: 0.35rem;
  padding: 0.6rem 0.75rem;
  background: var(--bg);
  border: 1px solid var(--accent);
  border-radius: 6px;
}
.generated-row .code-value {
  font-size: 1.05rem;
  letter-spacing: 0.04em;
  border: 0;
  background: none;
  padding: 0;
  color: var(--accent);
}

/* A checkbox used as a form field rather than inside a grid needs its own
   breathing room. */
dialog .check-item { margin-top: 1rem; padding: 0.5rem 0; }

/* ── A screen's title row ────────────────────────────────────────────────────
   A heading and a way out, and nothing else across the top. The paragraph that
   used to explain each list screen was read once and then re-read never, while
   costing four lines above the thing people came for on every visit afterwards.

   The X is the same .close-x the song and setlist editors already use, so
   "leave this screen" is one control in one corner everywhere. padding-right
   keeps a long heading from running under it. */
.screen-head {
  display: flex; align-items: flex-start;
  margin-bottom: 0.9rem; padding-right: 2.5rem;
}
.screen-head h1 { margin: 0; }

/* The rule under a screen's heading.
   One line, on every screen that has a heading: the band page, the song
   library, the setlists and a setlist's editor. On the band page it is what
   fences the tab strip — .tab-nav already draws its own border underneath, so
   the tabs now sit between two lines rather than floating at the top of the
   card.
   Its own class rather than a bare <hr>, because the browser default is an
   inset 3D groove with a full em of margin either side, which is neither the
   colour nor the weight of any other line in the Portal. */
.screen-rule {
  border: 0;
  border-top: 1px solid var(--border);
  margin: 0 0 1.5rem;
}

/* ── Tiles ───────────────────────────────────────────────────────────────────
   The song library and the setlists, as blocks that wrap rather than rows in a
   table. A table is one column however wide the monitor is; these fill the row
   and wrap when they run out, so the same library is five across on a desktop
   and one on a phone without either being told a width.

   auto-fill rather than auto-fit: auto-fit collapses the empty tracks and
   stretches a single tile across the entire screen, which makes a library of
   one song look like a banner. minmax's floor is what actually decides how many
   fit; 13rem holds a two-word title and an artist without wrapping either. */
.tile-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
  gap: 0.75rem;
}

/* The tile's BODY is the link. One target rather than a title inside a box, so
   the thing that looks clickable is the thing that is.

   ── IT USED TO BE THE WHOLE TILE, AND THE ELEMENT USED TO BE THE <a> ──

   Tiles now carry a footer with Edit and Perform on it, and an anchor cannot
   contain anchors — the markup is invalid and browsers resolve it by guessing,
   generally by closing the outer link early, which leaves a live target lying
   over part of the tile.

   So the tile is a container, .tile-link is the body, and the body is stretched
   back over the whole card by a ::after with inset: 0. What that preserves is
   the property worth preserving: pressing anywhere on the tile still opens the
   record, exactly as it did, while the two footer links sit above the stretched
   area on their own z-index and win the presses aimed at them.

   padding moves onto the parts, not the container: the footer's divider has to
   reach the edges of the tile, and it cannot if the tile is holding the inset. */
.tile {
  position: relative;
  display: flex; flex-direction: column;
  min-height: 4.5rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  color: var(--text);
}
/* .tile's hover is the shared rule further up. */
/* Still :focus-visible, and it still serves the performance bar's tiles, which
   are <button>s with no child link. :focus-within was tried here and is wrong:
   it matches a MOUSE click as well as a keyboard focus, so pressing the song
   tile in the bar would leave a ring around it — and that bar's whole design is
   that pressing something during a song is unremarkable. */
.tile:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.tile-link {
  display: flex; flex-direction: column; gap: 0.15rem;
  flex: 1;
  padding: 0.75rem 0.9rem;
  text-decoration: none;
  color: inherit;
  min-width: 0;
}
/* The stretch. Everything the tile holds that is NOT the footer belongs to this
   link, including the vibe stripe and the empty space under a short title. */
.tile-link::after {
  content: ""; position: absolute; inset: 0;
  border-radius: var(--radius);
}
/* The library tile's focus ring is drawn HERE, on the body link, rather than on
   the tile: the link is the focusable thing, and its box is the whole tile bar
   the footer — which carries its own ring on each half. Drawing it on the
   parent instead would need :focus-within, and that matches a mouse press too. */
.tile-link:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* ── THE BODY OF A TILE THAT IS NOT A TARGET ──
   Same box as .tile-link and none of its behaviour: no href, no stretched
   ::after, no focus ring, because there is nothing to focus. The song library
   uses it — see songs/_song_row.html for why the tile stopped being pressable —
   and the set lists still use .tile-link, so the two shapes have to sit side by
   side in this file rather than one replacing the other. */
.tile-body {
  display: flex; flex-direction: column; gap: 0.15rem;
  flex: 1;
  padding: 0.75rem 0.9rem;
  min-width: 0;
}
/* A tile whose body goes nowhere must not wash on hover: the wash is what says
   "this is a target", and saying it over a card where only the footer is one is
   the whole problem being fixed. The footer keeps its own hover, which is now
   the only thing on the card that has one. */
.tile-inert { cursor: default; }

/* ── EDIT | PERFORM ──────────────────────────────────────────────────────────
   The two things somebody opens a song or a setlist FOR, at the foot of its
   tile, splitting the width in half.

   Equal halves whatever the labels say, so the targets land in the same place
   on every tile of a wrapped grid and a thumb can find Perform without reading
   it. A divider between them and a rule above, because two words side by side
   with nothing between them read as one sentence.

   z-index over the stretched body link, which is the whole reason these are
   pressable at all. position must be set for z-index to apply. */
.tile-actions {
  position: relative; z-index: 1;
  display: flex;
  margin-top: auto;
  border-top: 1px solid var(--border);
}
.tile-action {
  flex: 1 1 50%;
  padding: 0.45rem 0.3rem;
  text-align: center;
  font-size: 0.8rem;
  color: var(--muted);
  text-decoration: none;
  /* Not a tap-highlight rectangle over a rounded corner. */
  -webkit-tap-highlight-color: transparent;
}
.tile-action + .tile-action { border-left: 1px solid var(--border); }
.tile-action:hover { background: var(--overlay); color: var(--text); }
.tile-action:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
/* Rounded to match the tile it is sitting in the bottom corners of. */
.tile-action:first-child { border-bottom-left-radius: var(--radius); }
.tile-action:last-child { border-bottom-right-radius: var(--radius); }
/* Perform is the one people are reaching for mid-rehearsal, and the reason this
   footer exists at all. Accent-coloured rather than boxed: a filled block in a
   grid of tiles reads as fifty buttons, which is louder than the library. */
.tile-action-perform { color: var(--accent); font-weight: 600; }
.tile-action-perform:hover { color: var(--accent-hover); }
/* A removed record has one action and it takes the row, rather than sitting in
   half of it with a gap beside it that looks like something failed to load. */
.tile-action:only-child {
  border-bottom-right-radius: var(--radius);
}

/* Titles wrap rather than ellipsing. A song is found by reading its name, and
   half a name is worse than a tile that is a line taller than its neighbours —
   which the grid absorbs, because the row takes the height of the tallest. */
.tile-name { font-weight: 600; line-height: 1.25; overflow-wrap: anywhere; }
.tile-sub { color: var(--muted); font-size: 0.85rem; overflow-wrap: anywhere; }

/* Pushed to the bottom of the tile's BODY, so the flags line up across a row
   whatever the titles above them did. Still margin-top: auto, but the box it is
   filling is now .tile-link rather than the tile — the footer below claims the
   bottom of the tile itself. */
.tile-flag {
  margin-top: auto; padding-top: 0.35rem;
  color: var(--muted); font-size: 0.72rem;
  text-transform: uppercase; letter-spacing: 0.04em;
}

/* ── BLOCK LISTS ─────────────────────────────────────────────────────────────
   The five lists on the band page — members, vibes, instruments, invites and
   requests — as cards rather than tables.

   The same move the song library and the set lists made, and for the same
   reason: a table is one column of rows however wide the monitor is, and it
   makes every row as wide as its widest column even when most cells are a date
   or a count. On a phone a seven-column table scrolls sideways, which is the
   worst way to read anything.

   WHY NOT .tile, WHICH ALREADY EXISTS. A tile IS a link — the whole thing is
   one target, which is what makes a library scannable. These five are not
   links: every one carries buttons that do different things to the same record,
   and a card that was itself a link with buttons inside it would be a nested
   target, which is both an accessibility problem and a misclick waiting to
   happen. So this is a sibling of .tile rather than a variant of it.

   ONE definition for all five. They are the same shape — an identity, some
   facts about it, and what you may do to it — and five near-identical card
   styles is how five screens drift apart. .claude/rules/portal-webapp.md makes
   the same point about the seven CRUD screens. */
.block-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(19rem, 1fr));
  gap: 0.75rem;
  list-style: none;
  margin: 0 0 0.75rem;
  padding: 0;
}

.block-card {
  display: flex;
  flex-direction: column;
  gap: 0.45rem;
  padding: 0.8rem 0.9rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

/* The identity: an avatar or a swatch beside a name, or just a name. A flex row
   so the two line up whether or not there is anything to the left of the text. */
.block-card-head { display: flex; align-items: center; gap: 0.6rem; min-width: 0; }
.block-card-title { display: flex; flex-direction: column; min-width: 0; line-height: 1.3; }
.block-card-name { font-weight: 600; overflow-wrap: anywhere; }
.block-card-sub { color: var(--muted); font-size: 0.82rem; overflow-wrap: anywhere; }

/* The facts. Label above value, several to a row, wrapping — which is what a
   table's header row was doing, said once per card instead of once per screen.
   margin-top: auto pushes them to the bottom so they line up across a row of
   cards whatever the names above them did. */
.block-card-facts {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 1.1rem;
  margin-top: auto;
  padding-top: 0.15rem;
  font-size: 0.8rem;
}
.block-card-fact { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; }

/* A record card's two extra rows, for the screens that were tables.
   .card-badges is what a Status column was; .block-card-actions below is what
   the last, unlabelled column was. Both sit between the name and the facts so a
   card reads top to bottom in the order the table read left to right. */
.card-badges {
  display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center;
}
.card-badges:empty { display: none; }
/* The actions row is .block-card-actions, defined once further down with the
   argument for the line above it. A second name for the same row was written
   here first and removed: two classes doing one job is how they drift. */
.block-card-fact-label {
  color: var(--muted); font-size: 0.75rem;
  text-transform: uppercase; letter-spacing: 0.04em;
}
.block-card-fact-value { color: var(--text); }
.block-card-fact-value .muted { font-size: 0.78rem; }
/* A fact that is a run of chips — the roles on a member — lays out as a row
   rather than a column, because chips already carry their own spacing. */
.block-card-fact-chips { flex-direction: row; flex-wrap: wrap; gap: 0.25rem; align-items: center; }

/* What you may do to it, fenced off from what it is. The rule matters more here
   than on a table row: in a card the buttons sit directly under the facts, and
   without a line between them "Remove" reads as one more fact. */
.block-card-actions {
  display: flex; flex-wrap: wrap; gap: 0.25rem 0.75rem;
  padding-top: 0.5rem;
  border-top: 1px solid var(--border);
}
/* .linkish is tuned for a table's action cell, where it sits in a tight row. In
   a card it is a tap target on a phone, so it gets the height for one. */
.block-card-actions .linkish { padding: 0.35rem 0.2rem; }

/* ── THE LAST CARD IS "ADD" ── (2026-09-24)
   Asked for by Jeff across every lighting list and more: the list is where a
   screen starts, and adding one is a card at the end of it that opens a dialog
   (PCW.formDialog in inline_crud.js). Dashed, because it is a place something
   can go rather than a thing that exists; the same accent hover every
   selectable record has. The whole card is one <button>, so Tab, Enter and
   Space come free.

   It stretches to the height of its row, so it sits as a peer of the cards
   beside it rather than as a stub at the top of the cell. */
.block-card-add { padding: 0; border-style: dashed; background: transparent; }
.block-card-add > button {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 0.35rem;
  width: 100%; height: 100%; min-height: 7.5rem;
  margin: 0; padding: 1rem;
  background: none; border: 0; border-radius: inherit;
  color: var(--muted); font: inherit; cursor: pointer;
}
.block-card-add-plus { font-size: 2rem; line-height: 1; color: var(--accent); }
.block-card-add-label { font-weight: 600; color: var(--text); }
.block-card-add-sub { font-size: 0.82rem; text-align: center; }
.block-card-add:hover { border-color: var(--accent); background: var(--tint-accent-faint); }
.block-card-add > button:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

/* ── SCREENS AND PROJECTORS: THE ADD-AND-EDIT DIALOG ── (2026-09-24)
   Lighting > Screens and Projectors moved its form into a dialog opened from
   the list (the last card adds, Edit on a card edits). Each field is a label
   stacked over its box, in a two-column grid: name, kind and notes take the
   full width; width, height and -- for a projector only -- the throw share a
   row. The throw box is REMOVED from the DOM for anything else rather than
   hidden (a hidden field still posts), so the grid simply has one cell fewer.

   `dialog input` already styles the text and number boxes; the select gets
   the same look here, because nothing else in a dialog styles one. */
.display-dialog-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
  gap: 0.75rem 1rem;
  margin-bottom: 0.75rem;
}
.display-dialog-field { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
.display-dialog-field > label { font-size: 0.85rem; color: var(--muted); }
.display-dialog-wide { grid-column: 1 / -1; }
.display-dialog select {
  width: 100%;
  padding: 0.55rem 0.65rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text);
  font-size: 0.95rem;
}

/* ── A KIT FORM IN A DIALOG ── (2026-09-24)
   Stands and Trusses and Stage Layouts: their add-and-rename forms moved into
   a dialog opened from the list's last card, and each has several measured
   boxes rather than scenes' one name. `.row` is not styled globally (see the
   note at the audio section), so it is laid out here and only here.

   Each label sits ABOVE its own box: a row of label-beside-box pairs that
   wraps can leave a label beside somebody else's box, which is the failure
   .labelled-field's own note warns about. A number box is as wide as a
   number; a name or a note takes the whole line. */
.kit-dialog { width: min(34rem, calc(100vw - 2rem)); }
.kit-dialog .row {
  display: flex; flex-wrap: wrap; gap: 0.75rem 1rem;
  align-items: flex-end;
  margin-bottom: 0.75rem;
}
.kit-dialog .labelled-field { flex-direction: column; align-items: stretch; gap: 0.25rem; }
.kit-dialog .labelled-field input[type="number"] { width: 6.5rem; }
.kit-dialog .kit-wide-field,
.kit-dialog .layout-name-field { flex: 1 1 100%; }


/* ── THE MIDI SCREENS' DIALOGS ── (2026-09-24)
   MIDI Gear and Incoming MIDI moved their forms into dialogs opened from the
   list (PCW.formDialog). Two need more than the default 30rem:

   - The rule is a SENTENCE with up to nine blanks. At 30rem it wrapped to six
     lines of one control each, which is the column of labelled fields the
     sentence exists to avoid. 44rem keeps it to about three.
   - A device's channels are a sixteen-row grid, a number and a name per row.
     It scrolls inside the dialog rather than the page, so Done stays in reach. */
.rule-dialog { width: min(44rem, calc(100vw - 2rem)); }
.channels-dialog { width: min(34rem, calc(100vw - 2rem)); }
.channels-dialog #channels-body { max-height: 60vh; overflow-y: auto; }
.channels-dialog .channel-grid { width: 100%; border-collapse: collapse; }
.channels-dialog .channel-grid th,
.channels-dialog .channel-grid td { padding: 0.2rem 0.4rem; text-align: left; }
.channels-dialog .channel-grid .channel-number {
  width: 4rem; color: var(--muted); font-variant-numeric: tabular-nums;
}

/* ── A PICTURE OF EACH LOOK ── (2026-09-14)
   On the Lighting Scenes list: the look from a seat in the house, the camera
   raised to 9 ft. Drawn by lighting.js after the list has arrived, so the box
   holds its shape on a dark ground until its picture lands, and then fades
   it in rather than popping. */
.scene-thumb {
  display: block;
  aspect-ratio: 16 / 9;
  max-width: 100%;
  overflow: hidden;
  border-radius: calc(var(--radius) - 2px);
  background: #0b0c0e;
}
.scene-thumb img {
  display: block; width: 100%; height: 100%; object-fit: cover;
  opacity: 0; transition: opacity 0.25s;
}
.scene-thumb img.is-drawn { opacity: 1; }

/* ── A CHASE CARD PLAYS ITS LOOKS ── (2026-09-24)
   On the Lighting Chases list: one frame per step, in running order, stacked
   in one 16:9 box, each holding that step's scene picture (scene_pictures.js).
   chases.js moves .is-on to the next frame every three seconds and the
   opacity transition below IS the cross fade -- still pictures, deliberately.
   A chase with no steps is the same dark box with a line in it, because an
   empty chase runs and shows nothing, which is worth seeing at a glance. */
.chase-thumb {
  position: relative;
  display: block;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border-radius: calc(var(--radius) - 2px);
  background: #0b0c0e;
}
.chase-frame {
  position: absolute; inset: 0;
  opacity: 0;
  transition: opacity 1s ease-in-out;
}
.chase-frame.is-on { opacity: 1; }
.chase-frame .scene-thumb { width: 100%; height: 100%; border-radius: 0; }
.chase-thumb-note {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  padding: 0.5rem; text-align: center;
  color: #9aa1ab; font-size: 0.85rem;
}
/* Asked for reduced motion: the first look holds (chases.js stops stepping),
   and nothing that does move fades. */
@media (prefers-reduced-motion: reduce) {
  .chase-frame { transition: none; }
}
/* The dialog's one tick sits in a column of full-width boxes; `dialog input`
   would stretch the box itself across the dialog. */
#chase-dialog label.check input { width: auto; }

/* ── THE CHASE PREVIEW ── (2026-09-14)
   A large dialog with the room in it, playing the chase. The canvas is sized
   by the window so the room is as big as the screen allows, and its dark
   ground shows while the room loads or when there is nothing to play. */
.chase-preview-open { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.75rem; }
.chase-preview-open button { width: auto; margin: 0; }
.chase-preview { width: min(72rem, 96vw); }
.chase-preview[open] { display: flex; flex-direction: column; max-height: 94vh; }
.chase-preview .dialog-head { flex-wrap: wrap; }
.chase-preview-stage { position: relative; }
.chase-preview .stage-canvas {
  display: block; width: 100%; height: min(64vh, 42rem);
  border-radius: var(--radius); background: #0b0c0e;
  touch-action: none; cursor: grab;
}
.chase-preview-note {
  position: absolute; inset: 0; margin: 0; padding: 2rem;
  display: flex; align-items: center; justify-content: center; gap: 0.3rem;
  flex-wrap: wrap; text-align: center; color: #d6dae0;
}
.chase-preview-note[hidden] { display: none; }
.chase-preview-note a { color: #9cc3ff; }
.chase-preview-pips {
  display: flex; flex-wrap: wrap; gap: 0.35rem;
  list-style: none; margin: 0.6rem 0 0; padding: 0;
}
.chase-preview-pips li {
  padding: 0.15rem 0.55rem; border: 1px solid var(--border); border-radius: 999px;
  font-size: 0.8rem; color: var(--muted);
}
.chase-preview-pips li.is-current {
  border-color: var(--accent); color: var(--text); background: var(--tint-accent-faint);
}
.chase-preview-hint { margin-right: auto; }

/* A card whose record is removed, expired or otherwise not in play. The whole
   card recedes rather than just its status chip, so a list showing removed rows
   reads at a glance instead of one badge at a time. The badge still says which
   of the four it is — that is not derivable from an opacity. */
/* ── A CARD THAT IS ITSELF THE CONTROL ──
   Vibes and instruments have an editor, so the whole card opens it and the
   Edit and Remove links that used to run along the bottom are gone. A real
   <button> wrapping the content rather than a click handler on the <li>: Tab,
   Enter, Space and the announced role all come free from the element.

   The padding moves from the card to the button so the target is the whole
   card and not an inset rectangle inside it. */
.block-card-clickable { padding: 0; }
.block-card-open {
  display: flex; flex-direction: column; gap: 0.45rem;
  width: 100%; height: 100%;
  padding: 0.8rem 0.9rem;
  margin: 0;
  text-align: left;
  background: none;
  border: 0;
  border-radius: inherit;
  color: inherit;
  font: inherit;
  cursor: pointer;
}
/* ── ONE HOVER, EVERYWHERE SOMETHING IS A SELECTABLE RECORD ──
   An accent border and the faintest accent wash. Nothing moves, nothing
   changes size, and the card does not become a different colour — the ground
   shifts by eight per cent of the accent, which is enough to say "this one"
   and not enough to fight a card carrying its own colour, like a vibe's swatch
   or a song's vibe stripe.

   Stated once, for every one of them, because that is the whole point: this
   was five different hovers doing the same job. A song tile tinted, a band card
   used a grey overlay with no border, a guide card recoloured only its name, a
   vibe swatch moved its border and nothing else, and a record card had a tint
   that a stray `button:hover` overrode with the FULL accent. Adding a sixth
   variant is now a deliberate act rather than the default.

   The stronger steps of the same wash mean something else and are left alone:
   --tint-accent-mid is a vibe that is CHOSEN, and --tint-accent-soft is a chord
   chip, which is small enough to need the extra alpha to read at all. */
.tile:not(.tile-inert):hover,
.band-card:hover,
.guide-card:hover,
.guide-choice:hover,
.library-item:hover,
.library-item:focus-visible,
.block-card-clickable:hover,
.vibe-option:hover .vibe-option-body {
  border-color: var(--accent);
  background: var(--tint-accent-faint);
}
/* The library item is dragged, so its focus ring would sit under the cursor
   for the whole drag. The tint above is what says it is focused. */
.library-item:focus-visible { outline: none; }
.block-card-open:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }

.block-card.is-inactive { opacity: 0.62; }
.block-card.is-inactive:hover { opacity: 1; }

/* ── Song vibes ───────────────────────────────────────────────────────────────
   A band's own vocabulary for how a song feels, and the colour that marks it.
   Two places draw one colour: the stripe down a song tile in the library, and
   the swatch beside the name on the band page's Vibes tab — which is what makes
   that tab readable as the LEGEND for the library rather than a list of words.

   The hue is the only thing the markup supplies, as the --vibe-color custom
   property set inline per row. It has to be inline: the value is per record and
   comes from the database, so there is no class it could be. Everything else —
   size, shape, position — is here, so the stripe and the swatch cannot drift
   apart into two different objects that happen to share a colour. */

/* The swatch on the Vibes tab and in the editor's preview. Deliberately the
   same 0.35rem width as the tile stripe below rather than a large square: the
   question somebody is answering while picking a colour is "will I still be
   able to tell this apart at the width it actually gets", and a big square
   answers a different one. */
.vibe-swatch {
  display: inline-block; flex: none;
  width: 0.35rem; height: 1.6rem;
  border-radius: 2px;
  background: var(--vibe-color, var(--border));
  /* A hairline edge, in the overlay token — which is white-ish on the dark
     theme and ink-ish on the light one, so it contrasts with the GROUND either
     way. The seeded colours are measured to clear 3:1 on both (V74), but the
     colour is the band's to choose and nothing stops them picking one that
     vanishes into the page. This is what keeps such a stripe legible as a
     stripe rather than as nothing at all. Inset, so it costs no layout. */
  box-shadow: inset 0 0 0 1px var(--overlay-bold);
}
.vibe-swatch-preview { height: 2rem; }

.vibe-color-field { display: flex; align-items: center; gap: 0.6rem; }
/* The native picker, sized down to something that sits on a form row. Left at
   its default it is a wide button that dominates a dialog of text inputs. */
.vibe-color-field input[type="color"] {
  width: 3rem; height: 2rem; margin: 0; padding: 0.1rem;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface); cursor: pointer;
}
.vibe-color-field .field-note { margin: 0; font-variant-numeric: tabular-nums; }

.vibe-text { display: flex; flex-direction: column; min-width: 0; }
.vibe-name { color: var(--text); font-weight: 500; }
/* A removed vibe, on the Vibes tab with "show removed" ticked. Dashed rather
   than only faded, matching .guide-card-inactive: opacity alone on a row whose
   whole point is a colour reads as a paler colour. */
.vibe-inactive { opacity: 0.6; }
.vibe-inactive .vibe-swatch { outline: 1px dashed var(--border); outline-offset: 1px; }

/* ── The instruments tab ────────────────────────────────────────────────────

   Same table as the vibes above, and the same treatment of a removed row.

   The tuning is monospaced everywhere it appears — the table, the standard
   catalogue, and the field note in the editor — because it is the same string
   the tab editor puts down the left of a stave, and a proportional face makes
   'e,B,G,D,A,E' and 'eb,Bb,Gb,Db,Ab,Eb' look like different KINDS of thing
   rather than two tunings. */
.instrument-inactive { opacity: 0.6; }

.instrument-name { display: block; }

.instrument-tuning {
  font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace;
  font-size: 0.82rem;
  color: var(--accent);
  background: none;
  padding: 0;
}


/* ── The page turner panel on the profile tab ───────────────────────────── */

.perform-keys { margin-top: 1.5rem; }
.perform-keys h3 { margin-bottom: 0.25rem; }

/* One row per action: label, current key, whether it is a default, and the two
   buttons. A grid rather than a table because the label carries a second line
   of explanation and a table cell would make that a second column nothing else
   fills. */
.perform-key {
  display: grid;
  /* label, key, gesture, default-flag, Learn, Reset */
  grid-template-columns: 1fr auto auto auto auto auto;
  align-items: center;
  gap: 0.5rem;
  padding: 0.4rem 0;
  border-top: 1px solid var(--border);
}

.perform-key-label { display: flex; flex-direction: column; }
.perform-key-label .muted { font-size: 0.78rem; }

/* The bound key, drawn as a key. Monospaced and boxed so "Page Down" reads as
   a thing you press rather than as a sentence fragment beside two buttons. */
.perform-key-value {
  font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace;
  font-size: 0.85rem;
  padding: 0.15rem 0.5rem;
  min-width: 6rem;
  text-align: center;
  border: 1px solid var(--border); border-radius: 4px;
  background: var(--surface);
}

/* Listening for a keystroke. The accent ring is the whole feedback that the
   page is waiting — the button also changes its words, but somebody who has
   just looked down at a pedal is not reading the button. */
.perform-key.is-learning .perform-key-value {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent);
}

/* The gesture this binding uses. Beside the key rather than under it, because
   the two together ARE the binding — "Page Down, double tap" is one answer and
   splitting it across two rows would read as two settings. */
.perform-key-gesture { font-size: 0.85rem; padding: 0.2rem 0.3rem; }

/* One of the numbers behind the gestures. Same row shape as a binding above so
   the two sections read as one screen, with a field where the key box sits. */
.perform-number {
  display: grid;
  grid-template-columns: 1fr auto auto auto;
  align-items: center;
  gap: 0.5rem;
  padding: 0.4rem 0;
  border-top: 1px solid var(--border);
}
.perform-number label { display: flex; flex-direction: column; }
.perform-number label .muted { font-size: 0.78rem; }
.perform-number-field { display: flex; align-items: center; gap: 0.4rem; }
.perform-number-field input { width: 6rem; }

@media (max-width: 40rem) {
  /* The label takes the full width and the controls wrap under it. Six columns
     on a phone put the key box at two characters wide. */
  .perform-key { grid-template-columns: auto 1fr auto auto; }
  .perform-key-label { grid-column: 1 / -1; }
  .perform-number { grid-template-columns: 1fr auto; }
  .perform-number label { grid-column: 1 / -1; }
}

/* The standard catalogue.

   A grid of cards rather than a list of names, because the TUNING is the point
   of the choice: "Guitar (Drop D)" and "Guitar (Open G)" are the same words
   apart until you can see what they are tuned to. auto-fill so the dialog uses
   whatever width it has without a media query per breakpoint. */
.standard-instrument-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
  gap: 0.5rem;
  max-height: 60vh;
  overflow-y: auto;
  padding: 0.25rem;
  margin: 0.75rem 0;
}

.standard-instrument {
  display: flex; flex-direction: column; gap: 0.15rem;
  text-align: left;
  padding: 0.5rem 0.6rem;
  background: var(--surface);
  color: var(--text);
  border: 1px solid var(--border); border-radius: 6px;
  cursor: pointer;
}

.standard-instrument:hover:not(:disabled) { border-color: var(--accent); }

.standard-instrument-name { font-weight: 600; }

/* Already in the band's list. Dimmed and not-allowed rather than hidden: the
   catalogue is also how somebody checks WHETHER they have one, and a name that
   vanishes once you own it makes that impossible to answer. */
.standard-instrument.is-held,
.standard-instrument:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

/* The vibe picker on the song form — radios that show their own answer.

   A <select> would be the obvious control and cannot do the one thing this
   field needs: an <option> renders as plain text on every platform, so the
   colour being chosen could not appear inside it. Radios in a wrapping row can,
   and they read as what they are — a small fixed set, all of it visible. */
.vibe-picker { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.5rem; }
.vibe-option { cursor: pointer; }
/* The radio itself is removed from view but NOT from the accessibility tree or
   the tab order — display:none or visibility:hidden would take it out of both,
   which is how a keyboard user ends up unable to reach the field at all. It
   stays where it is and the label beside it is what gets styled. */
.vibe-option input[type="radio"] {
  position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  clip-path: inset(50%); overflow: hidden; white-space: nowrap;
}
.vibe-option-body {
  display: flex; align-items: center; gap: 0.45rem;
  padding: 0.35rem 0.6rem;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--overlay-faint);
  font-size: 0.85rem;
}
/* The hover is the shared rule further up; only the CHOSEN state is local. */
/* The checked state carries a BORDER and a wash, not just a wash: the swatch
   inside is already a block of colour, and a background alone leaves two
   coloured rectangles competing to say which one is selected. */
.vibe-option input:checked + .vibe-option-body {
  border-color: var(--accent); background: var(--tint-accent-mid);
}
/* :focus-visible on the hidden input, drawn on the label — the reason the input
   is clipped rather than hidden. Without this a keyboard user arrowing through
   the group gets no indication of where they are. */
.vibe-option input:focus-visible + .vibe-option-body {
  outline: 2px solid var(--accent); outline-offset: 2px;
}
.vibe-option-name { color: var(--text); }
/* "None" gets an outlined swatch rather than no swatch, so every option is the
   same shape and the row does not jog where the colours start. */
.vibe-swatch-none {
  background: none; border: 1px dashed var(--border); border-radius: 2px;
}
/* A vibe this song still holds after the band removed it from its list. Struck
   through and named, because the field has to show the value that is actually
   stored — see the macro in songs/_fields.html for what happens if it does
   not. */
.vibe-option-removed .vibe-option-name { text-decoration: line-through; }
.vibe-option-note { color: var(--muted); font-size: 0.72rem; }

/* For text that exists only for a screen reader — the vibe's NAME on a song
   tile, where the stripe is a pseudo element and carries no text of its own.
   Clipped rather than display:none, which would take it out of the
   accessibility tree and defeat the entire purpose. */
.visually-hidden {
  position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  clip-path: inset(50%); overflow: hidden; white-space: nowrap;
}

/* The stripe on a song tile.
   A border-left on .tile would work and is wrong: .tile:hover changes
   border-color, so the stripe would turn accent-coloured whenever the pointer
   crossed it — the one moment somebody is looking straight at it. A pseudo
   element is independent of that, and rounds with the tile because it inherits
   the same radius on the corners it touches.

   Only drawn when the song HAS a vibe: the tile carries .song-tile-vibed and the
   custom property together, so a song with none keeps its full padding rather
   than a transparent gap where a stripe would be. */
.song-tile { position: relative; }
/* On the BODY of the tile, not on the tile. The tile holds no padding of its
   own any more — its Edit | Perform footer needs its divider to reach both
   edges — so the room the stripe needs is made by the element the text is in.
   The child selector is what keeps this off the bar's tiles, which have no
   .tile-link inside them and take their own narrower indent further down. */
.song-tile-vibed > .tile-link,
.song-tile-vibed > .tile-body { padding-left: 1.4rem; }
.song-tile-vibed::before {
  content: ""; position: absolute; left: 0; top: 0; bottom: 0;
  width: 0.35rem;
  border-radius: var(--radius) 0 0 var(--radius);
  background: var(--vibe-color);
  /* The right edge only. An absolutely positioned ::before is placed against
     the PADDING box, so the tile's own 1px border already frames this stripe on
     the other three sides; the edge that meets the tile's interior is the only
     one with nothing to define it. Same reasoning as the ring on .vibe-swatch. */
  box-shadow: inset -1px 0 0 var(--overlay-bold);
}

/* The same colour, as the edge of the song editor.

   A left border rather than the ::before the tile uses. The tile needed a
   pseudo element because .tile:hover repaints border-color, which would have
   turned the stripe accent-coloured under the pointer; a card has no hover
   state, so the simpler thing is available and the padding compensation is one
   declaration instead of three.

   Wider than the tile's 0.35rem on purpose. The tile stripe is read in a grid
   of twenty, where the job is telling neighbours apart; this one is alone on
   the screen and is read as "what is this song", so it wants to register from
   the corner of the eye rather than survive being compared. */
.song-card-vibed {
  border-left: 0.6rem solid var(--vibe-color);
  /* .card's own padding, less the extra the border now occupies, so the
     content inside does not shift sideways when a vibe is chosen or cleared —
     which it visibly did while this was being driven in the browser. The +1px
     is the 1px border the other three sides keep. */
  padding-left: calc(2rem - 0.6rem + 1px);
}

/* ── The vibe flow on a setlist tile ──────────────────────────────────────────
   The whole running order as one stripe down the LEFT EDGE: one equal section
   per song, in that song's vibe colour, top to bottom in playing order.

   Same edge, same width and same radius as .song-tile-vibed::before above, and
   that is the entire point of it being there rather than along the bottom. A
   colour on the left edge of a tile already means "this is its vibe" everywhere
   else in the Portal — the library, the setlist editor's blocks, the song
   card — so a setlist reads as the same object with several vibes instead of a
   different object with a chart under it. Down the left it also runs the way
   the set does: first song at the top, last at the bottom, which is the order
   the sets are printed in on the screen this tile links to.

   flex-direction: column with `flex: 1` on every section is what makes them
   equal without anyone counting: three songs give thirds, twenty-two give
   twenty-seconds, and the template never learns how many there are.
   `min-height: 0` is required — flex items refuse to shrink below their content
   box by default, and although these are empty, the default `min-height: auto`
   still stops a long setlist's sections from going below their basis, which
   would push the stripe past the bottom of the tile.

   Absolutely positioned against the tile's padding box, so it spans the full
   height regardless of the tile's own padding, and rounds into the two corners
   it touches. The tile pays for it with extra left padding rather than the
   stripe running under the text.

   Every setlist tile gets that padding, not only the ones with songs: an empty
   setlist beside a full one must not start its name a quarter of an inch
   further left, or the grid's rows stop lining up for a reason nobody can
   see. */
.setlist-tile { position: relative; }
/* Same split as .song-tile-vibed above, and for the same reason. */
.setlist-tile > .tile-link { padding-left: 1.4rem; }

.vibe-flow {
  position: absolute;
  left: 0; top: 0; bottom: 0;
  display: flex;
  flex-direction: column;
  width: 0.35rem;
  border-radius: var(--radius) 0 0 var(--radius);
  overflow: hidden;
}

/* ── EACH SECTION FADES INTO ITS NEIGHBOURS ──

   The section is its own colour through the middle and blends outward to the
   halfway point in each direction, where it meets the neighbour blending back.
   Both halves of every transition are drawn, one by each side, and they meet on
   exactly the same mixed colour — so the seam between two sections is a colour
   rather than a line.

   That is why the template computes --vibe-above and --vibe-below per section
   rather than this being one gradient on the parent. A single background over
   the whole stripe would say the same thing in fewer stops, and would give up
   the equal sections: `flex: 1` divides the height without anyone counting the
   songs, where a parent gradient needs a stop position per song and therefore
   needs to know how many there are.

   28% and 72% rather than 0% and 100%: a run that is pure colour in the middle
   still reads as ONE song, and a stripe that never holds a colour still reads
   as a smear. Roughly the middle half of each section is the vibe, the outer
   quarters are the transitions.

   oklab, not the default sRGB. Two saturated colours interpolated in sRGB pass
   through a muddy dark middle — red into green goes by way of brown — and this
   stripe is nothing but adjacent saturated colours. */
.vibe-flow-seg {
  flex: 1 1 0;
  min-height: 0;
  background: linear-gradient(
      to bottom,
      color-mix(in oklab, var(--vibe-color), var(--vibe-above))   0%,
      var(--vibe-color)                                          28%,
      var(--vibe-color)                                          72%,
      color-mix(in oklab, var(--vibe-color), var(--vibe-below)) 100%);
  /* One hairline, inset rather than a border — a border is part of the box and
     would make the sections unequal.

     It is the stripe's RIGHT edge, the one that meets the tile's interior: the
     tile's own 1px border frames the other three sides, so this is the only
     edge with nothing to define it. Exactly what .song-tile-vibed::before does,
     and why the two read as one object.

     ── AND WHAT IS GONE: THE DIVIDER BETWEEN SECTIONS ──

     There was a second inset hairline along the bottom of each section, so that
     four consecutive songs of one vibe read as four rather than as one long
     block. It cannot stay: it lands exactly where the blend is deepest, so it
     draws a hard line through the middle of every soft transition and undoes
     the thing above it.

     The count it protected is the cost, and it is small. Nothing here was ever
     countable — twenty-two equal sections in a 6rem stripe are two pixels each
     — and the song count in words is beside the stripe for anybody who wants
     the number. What this is for is the SHAPE of a night, which the blend shows
     better than the tiles did. */
  box-shadow: inset -1px 0 0 var(--overlay-bold);
}

/* A song with no vibe, or one whose vibe has been removed from the band's
   vocabulary, resolves to --overlay-mid IN THE TEMPLATE rather than to a class
   here. It has to: the section above a gap needs a colour to fade into, and a
   rule on the gap itself is invisible to its neighbours. The gap still occupies
   its full share of the stripe — the song is still in the set, and closing it
   up would redraw the running order as though it were not. */

/* ── The song library ──────────────────────────────────────────────────────── */
/* One rule for every soft-deleted thing on screen — a song tile, a setlist
   tile, a song inside a setlist whose library entry has been removed. It was
   .song-inactive, which stopped being true the moment a setlist wore it. All
   three mean the same thing: still here, and on screen only because somebody
   asked to see removed ones. */
.is-inactive { opacity: 0.55; }

/* Still in use inside a SETLIST — setlists/_song_row.html is the running order,
   which is a genuine table and stays one. These stopped being the library's when
   the library became tiles. */
.song-title { color: var(--text); text-decoration: none; display: block; }
.song-title:hover { color: var(--accent-hover); text-decoration: underline; }
.cell-song { min-width: 14rem; }
.cell-song .muted { display: block; }

/* Remove and restore, at the foot of a song's or a setlist's own details. Set
   apart from the form above it because it is not one of the record's fields and
   is not saved by the form's button. */
.danger-zone {
  margin-top: 2rem; padding-top: 1rem;
  border-top: 1px solid var(--border);
  max-width: 32rem;
}
.danger-zone .field-note { margin-top: 0.35rem; }
.cell-key, .cell-tempo, .cell-time, .cell-length, .cell-sections {
  white-space: nowrap;
}
/* The unit sits beside the number rather than in the column heading, because a
   heading called "Tempo" with a bare 100 under it reads as a percentage to
   anybody who is not already thinking in BPM. */
.unit { color: var(--muted); font-size: 0.78rem; }

/* A checkbox that belongs on the same line as the fields beside it, unlike the
   stacked label/input pairs .field produces. */
.check-inline {
  display: flex; align-items: center; gap: 0.5rem;
  color: var(--muted); font-size: 0.85rem;
  align-self: flex-end; padding-bottom: 0.55rem; white-space: nowrap;
}
.check-inline input[type="checkbox"] { margin: 0; }

/* An <a> that has to look like the page's buttons. Kept separate from .linkish,
   which is the opposite trade — a button that has to look like a link. */
.button-link {
  display: inline-block; padding: 0.55rem 1.1rem;
  background: var(--accent); color: var(--accent-ink);
  border-radius: var(--radius); text-decoration: none; font-weight: 600;
}
.button-link:hover { background: var(--accent-hover); }

/* ── One song ──────────────────────────────────────────────────────────────── */
.song-header { margin-bottom: 1.5rem; }
.song-header h1 { margin: 0; }
.song-header .sub { margin: 0.15rem 0 0.6rem; }
.song-summary { display: flex; flex-wrap: wrap; gap: 1.5rem; margin: 0 0 0.6rem; }
.song-fact { display: flex; flex-direction: column; line-height: 1.25; }
.song-fact-label {
  color: var(--muted); font-size: 0.68rem;
  text-transform: uppercase; letter-spacing: 0.08em;
}
.song-notes {
  margin: 0 0 0.6rem; color: var(--text); font-size: 0.9rem;
  border-left: 2px solid var(--border); padding-left: 0.75rem;
}

/* The two halves of a time signature, as one control. They mean nothing apart —
   the procedures refuse one without the other — so they are rendered together
   rather than as two fields somebody can half-fill without noticing. */
.timesig { display: flex; align-items: center; gap: 0.4rem; max-width: 12rem; }
.timesig input, .timesig select { margin: 0; }
.timesig input { width: 5rem; }
.timesig select { width: 5rem; }
.timesig-slash { color: var(--muted); font-size: 1.1rem; }

/* ── Sections ──────────────────────────────────────────────────────────────── */
/* The section cards. A running order, so the number leads and the name follows
   it — the card's position on screen is where the grid put it, and the number is
   what says where it sits in the song. */
.sections-list { grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); }
.section-head { gap: 0.4rem; }
.section-row .section-pos {
  flex: none; min-width: 1.5rem;
  color: var(--muted); font-size: 0.85rem;
}
.section-row .section-name {
  font-weight: 600; text-align: left;
  overflow-wrap: anywhere;
}
.section-row { cursor: default; }
.section-row[draggable="true"] .grip { cursor: grab; }
.section-row.dragging { opacity: 0.4; }
.cell-grip { white-space: nowrap; color: var(--muted); }
.grip { margin-right: 0.35rem; letter-spacing: -0.15em; }
.section-pos { font-variant-numeric: tabular-nums; }
.section-name { color: var(--text); }

/* An inherited value must not read as an overridden one. NULL in a section's
   tempo means "as the song" — showing the song's number here would make a
   section that genuinely holds it indistinguishable from one that merely
   follows along, which is the only thing this column is read for. */
.inherited { color: var(--muted); font-size: 0.78rem; font-style: italic; }

/* Space above the section links so they read as a band of their own rather than
   as a caption on the header above them. .link-row centres its contents, which
   is right on a form and wrong here. */
.band-sections { text-align: left; margin: 0 0 1.5rem; }

/* ── The theme switch ────────────────────────────────────────────────────────
   The last item in the account menu, so it is present on every signed-in page
   and absent from login — which is what "available at all times once logged in"
   means. It moved off the bar and into the menu because it is a preference,
   which is the same kind of thing as the three items above it, and a switch with
   a word beside it is the widest control on a row that had none to spare.

   A <button role="switch">, not a checkbox styled to look like one. The control
   has two states and no form to submit, which is exactly what role="switch"
   describes; a checkbox would need a label association and a name it never
   sends anywhere. aria-checked is maintained by theme.js.

   The track and knob are plain boxes rather than an image or an icon font: two
   colours and a transform, so they cost nothing and cannot fail to load. */
.theme-switch {
  display: flex; align-items: center; gap: 0.5rem;
  width: auto; margin: 0; padding: 0.25rem 0.4rem;
  background: none; border: none; border-radius: var(--radius);
  color: var(--muted); font-size: 0.9rem; font-family: inherit;
  cursor: pointer;
}
.theme-switch:hover { background: var(--overlay); color: var(--accent-hover); }
/* The focus ring is on the button, not the track: a keyboard user tabbing here
   needs to see the whole control, and outlining a 2rem box inside a 7rem one
   reads as something else having focus. */
.theme-switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.theme-switch-track {
  position: relative; flex: none;
  width: 2.1rem; height: 1.15rem;
  background: var(--overlay-bold);
  border: 1px solid var(--border);
  border-radius: 999px;
  transition: background 0.18s ease;
}
.theme-switch[aria-checked="true"] .theme-switch-track { background: var(--tint-accent-strong); }

.theme-switch-knob {
  position: absolute; top: 50%; left: 0.15rem;
  width: 0.8rem; height: 0.8rem;
  background: var(--muted);
  border-radius: 50%;
  transform: translateY(-50%);
  transition: transform 0.18s ease, background 0.18s ease;
}
.theme-switch[aria-checked="true"] .theme-switch-knob {
  background: var(--accent);
  transform: translate(0.95rem, -50%);
}

/* The theme's own name, not "Dark"/"Light". Jeff named these, and a switch that
   says which one you are in is more useful than one that says which way it
   points — the label reads as a state, and the knob shows the direction. */
.theme-switch-label { white-space: nowrap; }

/* ── The theme picker, in the account dialog (2026-09-21) ──
   Wider than the default dialog, because two columns of cards is what lets
   seven dark themes fit without the dialog becoming a scroll. The dialog still
   scrolls on a phone, where the grid drops to one column. */
#account-dialog { width: min(42rem, calc(100vw - 2rem)); }

.theme-slot {
  margin: 1rem 0 0; padding: 0; border: 0; min-width: 0;
}
.theme-slot legend {
  padding: 0; margin-bottom: 0.4rem;
  font-size: 0.85rem; color: var(--muted);
}
.theme-options {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
  gap: 0.4rem;
}
.theme-option {
  display: grid; grid-template-columns: auto 1fr; align-items: center;
  gap: 0.6rem; margin: 0;
  padding: 0.5rem 0.6rem;
  border: 1px solid var(--border); border-radius: 8px;
  background: var(--bg);
  cursor: pointer;
  color: var(--text);
}
.theme-option:hover { background: var(--overlay-strong); }
/* The radio itself is the focus target and the thing a screen reader reads, so
   it stays in the tree and is only drawn small; the card's border carries the
   checked state for everybody else. */
.theme-option input {
  position: absolute; opacity: 0; width: 1px; height: 1px; margin: 0;
}
.theme-option:has(input:checked) {
  border-color: var(--accent);
  background: var(--tint-accent-soft);
}
.theme-option:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Four chips — page, card, text, accent — in the THEME'S colours, not the
   page's. The whole point is to see a theme before switching to it. */
.theme-chips { display: flex; }
.theme-chips span {
  width: 0.8rem; height: 1.6rem;
  border: 1px solid rgba(128, 128, 128, 0.45);
}
.theme-chips span + span { border-left: 0; }
.theme-chips span:first-child { border-radius: 4px 0 0 4px; }
.theme-chips span:last-child { border-radius: 0 4px 4px 0; }

.theme-option-text { min-width: 0; }
.theme-option-name { display: block; font-size: 0.9rem; font-weight: 600; }
.theme-option-note { display: block; font-size: 0.75rem; color: var(--muted); line-height: 1.35; }

/* Measurements: the same cards with no swatch, so one column and narrower. */
.unit-options { grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr)); }
.unit-option { grid-template-columns: 1fr; }

/* Inside the menu there is always room for the label, so the narrow-screen rule
   that used to hide it is gone: it existed to save width on the bar, and the
   switch is not on the bar any more. */

/* ── Song guides ─────────────────────────────────────────────────────────────
   A guide is one person's work, so the list leads with WHOSE it is and what the
   reader may do with it — on a screen with four guides side by side, that is the
   only question a card has to answer. */
.guide-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.5rem; }
.guide-card {
  display: flex; align-items: center; gap: 0.75rem;
  border: 1px solid var(--border); border-radius: var(--radius);
  padding: 0.6rem 0.75rem;
}
.guide-card-inactive { opacity: 0.6; border-style: dashed; }
.guide-card-link {
  display: flex; align-items: center; gap: 0.75rem;
  flex: 1; min-width: 0; text-decoration: none; color: var(--text);
}
.guide-card-link:hover .guide-card-name { color: var(--accent-hover); }
.guide-card-body { display: flex; flex-direction: column; min-width: 0; }
.guide-card-name { font-weight: 600; }
.guide-card-meta { color: var(--muted); font-size: 0.78rem; }
.guide-card-tags { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }

/* ── "Which guide?" ──────────────────────────────────────────────────────────
   The one screen where a guide card is ONLY a link. Everywhere else a card
   carries badges and controls beside its link, so the link takes the room it
   needs and stops; here there is nothing to do with a guide but open it, and
   the target is the whole card.

   Same object, same class, one modifier — rather than a second card style that
   would drift from the first. */
.guide-choice { padding: 0; }
.guide-choice .guide-card-link { padding: 0.6rem 0.75rem; }
/* Hover: the shared rule further up. */
.guide-choice .guide-card-link:focus-visible {
  outline: 2px solid var(--accent); outline-offset: -2px;
}

.guide-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
.guide-header h1 { margin: 0; }
.guide-header .sub { margin: 0.2rem 0 0; }

.guide-section { margin: 1.75rem 0 0; }
.guide-section-head {
  display: flex; align-items: baseline; gap: 0.75rem;
  margin: 0 0 0.6rem; padding-bottom: 0.35rem;
  border-bottom: 1px solid var(--border);
  font-size: 1rem; text-transform: uppercase; letter-spacing: 0.06em;
}
.guide-section-facts {
  color: var(--muted); font-size: 0.78rem;
  text-transform: none; letter-spacing: 0;
}
.guide-blocks { display: grid; gap: 0.75rem; }

.guide-block {
  border: 1px solid var(--border); border-radius: var(--radius);
  padding: 0.6rem 0.75rem; background: var(--overlay-faint);
  /* A GRID ITEM, because .guide-blocks is a grid — so it defaults to
     min-width: auto and refuses to shrink below its content's intrinsic
     minimum. For a tablature block that minimum is the width of a six-string
     stave, which on a phone made the block wider than the column, the column
     wider than the page, and the whole document scroll sideways.

     .tabview already scrolls inside itself, with a sticky string label, and was
     never getting the chance: it cannot be narrower than a parent that will not
     shrink. This is the line that lets it do its job. */
  min-width: 0;
}
.guide-block.dragging { opacity: 0.4; }
.guide-block[draggable="true"] .grip { cursor: grab; }
.guide-block-head {
  display: flex; align-items: center; gap: 0.5rem;
  margin-bottom: 0.5rem; color: var(--muted); font-size: 0.78rem;
}
/* No longer overriding a text-transform: the uppercase kind label that used to
   sit beside it is gone, so there is nothing to opt out of. */
/* ── THE BLOCK'S OWN NAME, AND THE HEADING BESIDE IT ──

   Two labels on one line, and they have to be told apart at a glance or the
   pair is worse than either alone. The NAME leads and carries the weight: it is
   what its author files the block under and the thing you are scanning for in a
   long arrangement. The HEADING follows in the header's own muted colour,
   because it is a sentence about why the block is there rather than a label.

   Only on the editor and the library — .perform-view never renders the name at
   all, so there is nothing to style there. V88 has the reasoning. */
.guide-block-name {
  font-weight: 600; font-size: 0.9rem; color: var(--text);
  /* A name long enough to wrap would push the actions onto a second line and
     make every block in the arrangement a different height. It is a label; one
     line of it is the label. */
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  max-width: 22rem;
}
.guide-block-title { margin: 0; font-size: 0.92rem; color: var(--text); }
/* When a block has both, the heading steps back so the name reads as the label
   and the heading as what it is. On its own it keeps the full weight above. */
.guide-block-name + .guide-block-title {
  font-weight: 400; font-size: 0.85rem; color: var(--muted);
}
.guide-block-actions { margin-left: auto; white-space: nowrap; }

/* == ON A PHONE THE ROW BREAKS IN TWO, NOT THE WORDS ==
   The header is one nowrap row - grip, avatar, name, title, then the arrows,
   Edit and Remove - and the actions win the argument about width. Measured at
   390px: the actions took 136px of the screen, the name box collapsed to 11px,
   8px and for the longest of them to nothing at all, and "Before you start"
   set as three stacked words. The row still overran, which made the guide
   editor the only screen in the Portal whose document scrolled sideways.

   So below 40rem the row wraps and the actions take a line of their own. The
   name gets the full width and stops being an ellipsis; the actions get a
   full-width row, which is also what gives them room for the 44px targets
   `(pointer: coarse)` asks for. */
@media (max-width: 40rem) {
  .guide-block-head { flex-wrap: wrap; }
  .guide-block-name { max-width: none; white-space: normal; overflow: visible; }
  .guide-block-actions {
    flex: 1 0 100%;
    margin-left: 0;
    padding-left: 1.6rem;   /* clear of the grip, so it reads as this block's */
    display: flex; align-items: center; gap: 0.35rem;
  }
}

/* ── THE PAGE BREAK, IN THE ARRANGEMENT ──

   A rule across the column with a word on it, drawn as the absence of content
   it is: no card, no background, nothing that reads as a block with something
   in it. It is still a .guide-block, so it still has a grip, arrows and Remove
   — where it sits is the only thing it has.

   Only ever seen here. On the two performance views static/js/perform.js
   consumes these to cut the guide into pages and removes them from the flow, so
   there is no performance rendering of this at all. */
.guide-block-break {
  border: none !important;
  background: transparent !important;
  padding: 0.15rem 0 !important;
}
.guide-break-mark {
  display: flex; align-items: center; gap: 0.6rem;
  margin: 0.1rem 0 0;
  color: var(--muted); font-size: 0.75rem;
  text-transform: uppercase; letter-spacing: 0.1em;
}
/* The rules either side of the words. Dashed, because a page break is where the
   chart is cut rather than a line drawn on it — and because a solid rule at
   this weight reads as a section divider, which is a different thing two
   inches away. */
.guide-break-mark::before,
.guide-break-mark::after {
  content: ""; flex: 1 1 0;
  border-top: 1px dashed var(--border);
}
.guide-break-text { flex: none; color: var(--accent); font-weight: 600; }
/* "when performing" — the qualifier that stops this reading as broken. The
   editor plainly does not start a new page here, and it is not supposed to. */
.guide-break-note {
  flex: none; text-transform: none; letter-spacing: 0; font-style: italic;
}

/* ── The key-and-tempo blocks ─────────────────────────────────────────────────
   SONG_FACTS and SECTION_FACTS, drawn wherever their author dropped them.

   A <dl>, because that is what this is: three labelled values. Laid out as a
   row of label-over-value pairs rather than a sentence, so it can be read at a
   glance from a stand — "G" under "Key" is one saccade, where "G · 92 bpm · 4/4"
   has to be parsed before the key can be found in it.

   It wraps rather than scrolling. Three items fit on any screen this is read on;
   the wrap exists so a long key name on a phone drops the time signature to a
   second line instead of pushing it off the side. */
.factsblock {
  display: flex; flex-wrap: wrap;
  gap: 0.4rem 1.75rem;
  margin: 0;
}
.factsblock-item { display: flex; flex-direction: column; gap: 0.05rem; }
.factsblock-label {
  color: var(--muted); font-size: 0.75rem;
  text-transform: uppercase; letter-spacing: 0.08em;
}
/* A <dd> has a browser default margin-inline-start of 40px, which in a flex
   column shows up as the value being indented from its own label. */
.factsblock-value {
  margin: 0;
  font-size: 1.05rem; font-weight: 600;
  font-variant-numeric: tabular-nums;
}

/* ── A VALUE THIS SECTION SETS FOR ITSELF ──
   Only ever on a SECTION_FACTS block, and only on a field the section actually
   overrides rather than inherits from the song. It is the entire reason to
   choose that block over the song one: beside a bridge that modulates it says
   the key is B flat AND that the change happens here.

   The accent plus a left edge, not colour alone: this is the one distinction on
   the block that carries meaning, and a reader who cannot separate the two
   colours would otherwise get no signal at all. The <dd> also carries the
   difference in words for a screen reader — see guides/_block.html. */
.factsblock-here {
  padding-left: 0.5rem;
  border-left: 2px solid var(--accent);
}
.factsblock-here .factsblock-label { color: var(--accent); }

/* Nothing recorded on the song yet. Only drawn where somebody can act on it,
   which is why there is no performance-view rule below to hide it. */
.factsblock-empty { margin: 0; }

/* On a stand it is bigger and it loses the box.
   .perform-view already strips a block's border and padding — see THE
   PERFORMANCE RENDERING — so all that is left to do is make the values readable
   at arm's length, which is the whole reason somebody placed one. */
.perform-view .factsblock-value { font-size: 1.3rem; }
.perform-view .factsblock { gap: 0.4rem 2.25rem; }

/* The fixed page-break row in the library, set apart from the blocks below it:
   it is a control rather than one of this member's riffs, and it has no avatar,
   no version and nothing to preview. */
/* It does NOT scroll. .library-list carries overflow-y: auto for the long list
   of blocks below, and this one holds two controls — inheriting the scroll gave
   the panel a second scrolling region 137px tall showing 675px of content, one
   nested inside the other, which is a nested scroller doing no work at all. */
.library-fixed { margin-bottom: 0.6rem; overflow-y: visible; flex: none; }
.library-item-break,
.library-item-facts { border-style: dashed; }
.library-item-break .library-item-title,
.library-item-facts .library-item-title { color: var(--accent); }

/* Notes come out of the sanitiser, so only the allow-listed tags can appear
   here — which is why this can style them by element without guarding against
   anything unexpected. */
.guide-notes { font-size: 0.94rem; }
.guide-notes p { margin: 0 0 0.5rem; }
.guide-notes p:last-child { margin-bottom: 0; }
.guide-notes ul, .guide-notes ol { margin: 0 0 0.5rem; padding-left: 1.3rem; }
.guide-notes h3, .guide-notes h4 { margin: 0.75rem 0 0.3rem; font-size: 0.95rem; }
.guide-notes blockquote {
  margin: 0 0 0.5rem; padding-left: 0.75rem;
  border-left: 2px solid var(--border); color: var(--muted);
}
.guide-notes code { font-size: 0.85rem; }
.guide-notes a { color: var(--accent); }

/* ── ChordPro ──
   One parsed structure, three looks, chosen by the class on the wrapper. Both
   stacked modes lay each segment out as an inline-block column with the chord
   above its own words, so the chord sits over the syllable it belongs to
   without anything measuring characters — which is what the old implementation
   did, and it only lined up in a monospaced font. */
.chordpro { font-size: 0.95rem; line-height: 1.45; }
.cp-line { margin: 0 0 0.15rem; }
.cp-gap { height: 0.75rem; }
.cp-chord { color: var(--accent); font-weight: 600; }
.cp-text { white-space: pre-wrap; }

/* Chords in the line, the way a plain-text chart reads. */
.chordpro-inline .cp-chord { margin-right: 0.1rem; font-size: 0.85rem; }
.chordpro-inline .cp-chord::before { content: "["; opacity: 0.5; }
.chordpro-inline .cp-chord::after  { content: "]"; opacity: 0.5; }

/* Chords above the words. The segment becomes a column; the chord gets its own
   line inside it and the lyric follows underneath, so a long chord name widens
   its own column rather than pushing the next chord out of place. */
/* ── THE LYRIC ROW IS ALWAYS RESERVED, EVEN WHEN THERE ARE NO WORDS ──

   vertical-align: bottom lines the columns up by their FEET, which is what puts
   each chord directly over the syllable it belongs to. It also means a column
   with no lyric under it is SHORTER than its neighbours, so its chord sits
   lower — and a line ending in a chord, "[Dm]on the wall[G]", staircased the
   last one down the screen.

   min-height on the lyric row gives every column the same floor whether or not
   it has words in it. In em, so it tracks the font size rather than fixing a
   pixel height the reader can change out from under it. */
.chordpro-chords_over_lyrics .cp-seg,
.chordpro-positioned .cp-seg { display: inline-block; vertical-align: bottom; }
.chordpro-chords_over_lyrics .cp-chord,
.chordpro-positioned .cp-chord { display: block; font-size: 0.8rem; line-height: 1.2; }
.chordpro-chords_over_lyrics .cp-text,
.chordpro-positioned .cp-text { display: block; min-height: 1.45em; }
.chordpro-chords_over_lyrics .cp-line,
.chordpro-positioned .cp-line { margin-bottom: 0.35rem; }

/* A line with no words at all — the chords for a passage that has none, or a
   turnaround. There is nothing to sit above, so the reserved lyric row is
   dropped and the chords simply run across. Without this the line is all floor
   and no ceiling: every column reserves a lyric that never arrives. */
.cp-line-chords .cp-text { display: none; }
.cp-line-chords .cp-seg { vertical-align: top; }
.cp-line-chords .cp-chord { margin-right: 0.35rem; }

/* Badges: the same columns, with the chord drawn as a chip. */
.chordpro-positioned .cp-chord {
  background: var(--tint-accent-mid); border-radius: 999px;
  padding: 0.02rem 0.4rem; margin-bottom: 0.1rem;
  width: fit-content;
}

.cp-chordlist { margin: 0.6rem 0 0; display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; }
.cp-chordlist-label {
  color: var(--muted); font-size: 0.75rem;
  text-transform: uppercase; letter-spacing: 0.08em; margin-right: 0.15rem;
}
.cp-chip {
  border: 1px solid var(--border); border-radius: var(--radius);
  padding: 0.05rem 0.4rem; font-size: 0.8rem; color: var(--accent);
}
/* A chord the chart DEFINED a voicing for, rather than one it merely played.
   Dashed, so the two read as the same kind of thing with one of them carrying
   something extra — which is exactly the relationship. */
.cp-chip-defined { border-style: dashed; }

/* ── THE CHORDS THIS SONG ALREADY USES ─────────────────────────────────────
   A row of one-click chords under "Add a chord" in the block editor, scanned
   from the song's other words-and-chords blocks. guides.chords_used_in_song has
   the account of what is in it and why it is ordered the way it is. */
.chord-used { display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem 0.6rem; }
.chord-used-label {
  color: var(--muted); font-size: 0.7rem;
  text-transform: uppercase; letter-spacing: 0.08em;
}
.chord-used-list { display: flex; flex-wrap: wrap; gap: 0.3rem; }
/* Each one is a button because pressing it does something, and it carries the
   same thumbnail the chart does — so the shape somebody is about to reuse is
   the shape they will see once it is in. */
.chord-used-chip {
  width: auto; margin: 0; padding: 0.2rem 0.4rem;
  display: inline-flex; align-items: center; gap: 0.3rem;
  min-height: 2rem;
  background: none; color: var(--accent);
  border: 1px solid var(--border); border-radius: var(--radius);
  font-size: 0.85rem; font-weight: 600;
}
.chord-used-chip:hover, .chord-used-chip:focus-visible {
  border-color: var(--accent); background: var(--tint-accent-soft);
}
.chord-used-shape { display: inline-flex; flex: none; }
.chord-used-shape .chorddiagram.is-mini {
  display: block; height: 1.75rem; width: auto; max-width: none;
}

/* ── THE THUMBNAIL ON A CHORD ─────────────────────────────────────────────────
   A chord that names a voicing carries a picture of it, about two lines of text
   tall. Deliberately too small to read: it is a reminder of which of two shapes
   was agreed, and the block's chord strip is where a diagram gets read.

   Sized in `em` so it grows with the chart — the performance view scales
   .chordpro by setting one font size, and the thumbnail has to come with it. */
.cp-chord-shape {
  display: inline-block;
  /* Sat on the chord's own baseline rather than centred on it. Centred, a box
     two and a half times the height of the text floats away from the chord it
     belongs to and reads as a separate thing; on the baseline it hangs above
     the chord the way the chord hangs above the word. */
  vertical-align: baseline;
  margin-left: 0.2em;
}
.cp-chord-shape .chorddiagram.is-mini {
  display: block;
  /* 2.6em of the CHORD, which is itself smaller than the lyric — so about two
     and a half lines of chord text. Arrived at by looking: at 2.1 the six
     strings and five frets crowd into twenty-odd pixels and the dots stop
     separating from the grid, which is the point at which a reminder stops
     reminding anybody of anything. */
  height: 2.6em;
  width: auto;
  max-width: none;
}
/* Smaller in INLINE mode. There the chord sits between the words rather than on
   a row of its own, so the thumbnail sets the height of a line of lyric — and a
   picture two lines tall in the middle of a sentence pushes the whole verse
   apart. The stacked modes have a row to put it in and can afford the size. */
.chordpro-inline .cp-chord-shape .chorddiagram.is-mini { height: 1.9em; }
/* ── THE GRID HAS TO SURVIVE THE SHRINK ──

   The full diagram draws its fret and string lines in --border, which is tuned
   to separate two panels and is very nearly the panel colour. At full size that
   is right: the lines are scaffolding and the dots are the ink. At thumbnail
   size it disappears completely, and what is left is four dots floating in
   space that read as nothing at all — which is what the first version did.

   So the mini draws its grid in --muted, which is a TEXT colour and therefore
   survives being small, held back with opacity so it still reads as scaffolding
   rather than competing with the dots. */
.chorddiagram.is-mini .cd-fret,
.chorddiagram.is-mini .cd-string {
  stroke: var(--muted);
  stroke-width: 0.9;
  opacity: 0.5;
}
.chorddiagram.is-mini .cd-open { stroke-width: 0.9; }
.chorddiagram.is-mini .cd-mute { font-size: 5.5px; }
.chorddiagram.is-mini .cd-fretnum { font-size: 6px; }

/* ── WHICH SHAPE, ON THE CHORD ITSELF ────────────────────────────────────────
   Only drawn where the author named a voicing, so it is rare by construction
   and reads as an instruction rather than as decoration.

   NOT A TOOLTIP. This is the one piece of information a player needs at the
   instant they hit the chord, and the screen it has to work on is a tablet on a
   stand at arm's length — where there is no pointer to hover with and nobody is
   going to try. It is printed, and it is printed big enough to read from where
   the reader is standing. */
.cp-chord-voicing {
  display: inline-block;
  /* No left margin: the gap is a real space in the markup, so the chord copies
     and reads as two words. See the note in guides/_block.html. */
  padding: 0 0.28em;
  border-radius: 3px;
  background: var(--tint-accent-soft);
  color: var(--accent);
  /* Smaller than the chord and no smaller. A voicing nobody can read is the
     same as no voicing, and this only appears where somebody deliberately said
     the ordinary shape would not do. */
  font-size: 0.78em;
  font-weight: 600;
  white-space: nowrap;
  vertical-align: baseline;
}
/* The chart asks for a voicing the band no longer has. The strip already draws
   the substitute and marks it; this marks the same thing at the chord, because
   this is where somebody is reading. */
.cp-chord-voicing.is-missing {
  background: none;
  color: var(--warning);
  text-decoration: line-through;
}

/* In the STACKED modes the chord already sits on its own line above the words,
   so the label rides beside it without pushing anything about. In INLINE mode
   the chord is in the text flow and the brackets are drawn by ::before and
   ::after on .cp-chord — the label has to sit INSIDE them or it reads as part
   of the lyric, which is why it is a child of .cp-chord rather than a sibling. */
.chordpro-chords_over_lyrics .cp-chord-voicing,
.chordpro-positioned .cp-chord-voicing { display: inline-block; }

/* On a stand, larger against the chord it belongs to than it is on the editor.
   The chord itself is already reduced in the stacked modes, so a label at 0.82
   of it landed near ten pixels — readable at a desk and not at arm's length,
   which is the only place this actually gets used. The fitter scales .chordpro
   as a whole, so it stays in proportion with everything around it. */
.perform-view .cp-chord-voicing { font-size: 0.9em; }

/* ── ON A STAND THE SHAPE SITS ABOVE THE CHORD ──
   Asked for after a gig, 2026-09-13. Beside the name, a thumbnail pushes the
   next word along by its own width, so a line with shapes on it is wider than
   the same line without and reads as name, picture, word. Above, the name stays
   over its syllable and the shape rides on top of it, which is how a printed
   chart draws a diagram over a chord.

   ── A TABLE WITH A CAPTION, BECAUSE A FLEX COLUMN GOT THE BASELINE WRONG ──

   The obvious build is a flex column with the shape on top. Measured in
   Chromium, 2026-09-13: an inline flex column takes its baseline from the TOP
   item, so the shape's foot landed on the lyric's baseline and the chord name
   hung eighteen pixels below the words it belongs to.

   An inline TABLE has exactly the rule wanted, and has had it since CSS 2: its
   baseline is its FIRST ROW. The name — and in inline mode its brackets, which
   are ::before and ::after on the chord — go into that row; the shape is a
   caption on top, which is outside the rows and so never moves the baseline.
   A table also grows to fit its caption, so a short name under a wide shape
   takes the shape's width and a neighbouring chord cannot land on top of it.
   And a background paints the table box and not the caption, so the badge
   mode's pill stays round the name with no extra rule.

   @media screen: the PDF keeps the shape beside the name. WeasyPrint is not a
   browser and draws table captions its own way, and only the stand was asked
   about. */
@media screen {
  .perform-view .cp-chord.has-shape {
    display: inline-table;
    text-align: center;
  }
  .perform-view .cp-chord.has-shape .cp-chord-shape {
    display: table-caption;
    caption-side: top;
    margin: 0 0 0.1em;
  }
  .perform-view .cp-chord.has-shape .cp-chord-shape .chorddiagram.is-mini {
    margin: 0 auto;
  }
  /* In the stacked modes the chord sits over the syllable it falls on, so the
     name keeps to the left rather than centring under a wider shape. */
  .perform-view .chordpro-chords_over_lyrics .cp-chord.has-shape,
  .perform-view .chordpro-positioned .cp-chord.has-shape {
    display: table;
    text-align: left;
  }
  .perform-view .chordpro-chords_over_lyrics .cp-chord.has-shape .chorddiagram.is-mini,
  .perform-view .chordpro-positioned .cp-chord.has-shape .chorddiagram.is-mini {
    margin: 0;
  }
}

/* ── The chord strip under a words-and-chords block ─────────────────────────
   Shapes rather than names, when a shape can be drawn. guides/_block.html has
   the argument; this is the layout.

   A row that WRAPS rather than scrolls, unlike the chart above it: a diagram is
   a fixed little square and eight of them on two lines is fine, where a lyric
   line broken in half has stopped being a lyric line. */
.cp-chordlist.has-diagrams { align-items: flex-start; gap: 0.5rem 0.7rem; }
.cp-chordlist.has-diagrams .cp-chordlist-label { align-self: center; }
.cp-shape {
  display: flex; flex-direction: column; align-items: center; gap: 0.1rem;
  flex: none; width: 4.25rem;
}
.cp-shape .chorddiagram { max-width: 4.25rem; }
.cp-shape-name { font-size: 0.8rem; font-weight: 600; color: var(--accent); }
/* Which voicing, and only when the chart named one — "E shape" under all five
   shapes of a chord nobody chose between would be five labels saying nothing. */
.cp-shape-voicing { font-size: 0.65rem; color: var(--muted); text-align: center; }
/* The chart asked for a voicing this band no longer has. Marked rather than
   silently substituted: retiring a voicing is documented as changing what a
   chart DRAWS, and a reader looking at a substitute should be able to tell.
   The shape shown is the fallback, which is still a correct chord. */
.cp-shape-voicing.is-missing { color: var(--warning); }

/* On a music stand the diagrams grow with everything else the fitter scales,
   so they are sized in em there rather than rem. .perform-view already sets a
   font size on .cp-chordlist; this makes the pictures follow it. */
.perform-view .cp-shape { width: 4.9em; }
.perform-view .cp-shape .chorddiagram { max-width: 4.9em; }

/* ── The block editor ──
   Wider than every other dialog in the Portal, because it is the only one where
   somebody types a whole verse rather than filling in a field. 30rem is right
   for a name and a tick list and cramped for a chart, which wraps at a width
   that has nothing to do with where the lines actually break.

   70vw, floored at 30rem so it cannot end up NARROWER than the default dialog
   on a small screen, and capped so it still fits the viewport with the 1rem
   margin the other dialogs leave. Height follows content up to 85vh and then
   scrolls inside itself — a dialog taller than the window puts its Save button
   somewhere the reader cannot reach.

   ── THIS WAS DELETED ONCE, BY ACCIDENT, AND NOTHING NOTICED ──

   Taking the ChordPro block type out meant removing a run of CSS, and the range
   removed ran from the ChordPro banner to `.block-pane` — which swept up this
   whole section on its way past. Every test still passed and every page still
   rendered; the only symptom was that the block editor was suddenly narrow, for
   days, until somebody said so.

   Worth a note rather than a shrug, because the same shape will come up again:
   a deletion bounded by "from this comment to that selector" is only safe if
   somebody has read what is between them. */
#block-dialog {
  width: clamp(30rem, 70vw, calc(100vw - 2rem));
  max-height: 85vh;
  overflow-y: auto;
}

/* The content area: 90% of the dialog, as asked, and tall enough to see a verse
   without scrolling. min-height in vh rather than a row count, because the
   point of the wider dialog is that it scales with the screen. */
#block-dialog #block-chordpro,
#block-dialog .notes-toolbar,
#block-dialog .notes-editor {
  width: 90%;
}
#block-dialog #block-chordpro,
#block-dialog .notes-editor { min-height: 40vh; }

/* The short fields do NOT follow. A one-line "Heading" box stretched across
   1300px of a wide monitor is not more usable than a narrow one, it is just
   harder to aim at — the long content area is the thing that needed the room. */
#block-dialog #block-name,
#block-dialog #block-title,
#block-dialog #block-display-mode { max-width: 26rem; }

.block-pane { margin-top: 0.5rem; }
/* Where a new block is going — "Into Verse 1, after Verse 1 Tablature 2". Just
   under the dialog's title, because it is the answer to the question the form
   no longer asks. */
.block-dialog-where { margin: -0.4rem 0 0.75rem; }
/* #block-chordpro used to be a monospaced textarea, always — which made the
   block's own fixed-width switch look like it did nothing while writing, since
   the editor was fixed-width either way. It is a formatted editor now
   (.notes-editor.is-chords, below) and follows the switch. */
.notes-toolbar {
  display: flex; gap: 0.25rem; flex-wrap: wrap;
  padding: 0.3rem; margin-bottom: -1px;
  border: 1px solid var(--border); border-radius: var(--radius) var(--radius) 0 0;
}
.notes-toolbar .linkish { padding: 0.15rem 0.45rem; }
.notes-editor {
  min-height: 8rem; padding: 0.6rem 0.7rem;
  border: 1px solid var(--border); border-radius: 0 0 var(--radius) var(--radius);
  background: var(--bg); color: var(--text);
  font-size: 0.94rem; overflow-y: auto;
}
.notes-editor:focus { outline: 2px solid var(--accent); outline-offset: -2px; }
.notes-editor p { margin: 0 0 0.5rem; }
.notes-editor ul, .notes-editor ol { margin: 0 0 0.5rem; padding-left: 1.3rem; }

/* ── IMAGE blocks ────────────────────────────────────────────────────────────
   The rendered block first, then the editor's pane.

   overflow: hidden on the frame and a transform on the <img> inside it is what
   makes the crop a CROP rather than a resize: zooming in enlarges the picture
   behind a fixed opening instead of pushing the blocks after it down the page.
   The three numbers come from guides.py already clamped. */
.guide-image {
  overflow: hidden;
  border-radius: var(--radius);
  background: var(--overlay-soft);
  /* The frame follows the image's own aspect ratio at scale 100, because the
     img is in normal flow and the transform does not affect layout. That is the
     wanted behaviour: an uncropped picture takes exactly the room it needs. */
  display: flex;
}
.guide-image img {
  max-width: 100%;
  height: auto;
  display: block;
  /* From the centre, so zooming keeps the middle of the picture in view rather
     than walking it off the top-left corner. Matches the editor's preview. */
  transform-origin: center center;
}

/* ── THE CROP ────────────────────────────────────────────────────────────────
   A block that shows only part of its picture. The five custom properties come
   off the block, already clamped; guides/_block.html says what each one is.

   The whole trick is that the box gets its SHAPE from the rectangle's own pixel
   size, and the image is then over-sized inside it so that the chosen rectangle
   exactly fills the box:

     the image is 100/w times as wide as the box, so w% of it spans the box;
     the same for the height;
     and it is pulled left and up by the rectangle's own offset, expressed as a
       share of the image rather than of the box — x/w, not x.

   No pixel dimensions are needed anywhere, which is the point: nothing at
   render time knows how big the picture is. */
.guide-image.is-cropped {
  position: relative;
  /* The rectangle's own pixel size does both jobs: it gives the box its shape,
     and it stops the box growing past the picture. Without the cap, a
     320-pixel screenshot cropped to its middle would be stretched across the
     whole column and come out blurred — which is what the first version did. */
  aspect-ratio: var(--crop-pw) / var(--crop-ph);
  width: 100%;
  max-width: calc(var(--crop-pw) * 1px);
  /* Not a flex row here — the img is taken out of flow, so a flex container
     would have nothing left to size itself from. */
  display: block;
}
.guide-image.is-cropped img {
  position: absolute;
  left: calc(var(--crop-x) / var(--crop-w) * -100%);
  top: calc(var(--crop-y) / var(--crop-h) * -100%);
  width: calc(100% / var(--crop-w) * 100);
  height: calc(100% / var(--crop-h) * 100);
  /* Both dimensions set, which cannot distort: the box was given the
     rectangle's own aspect ratio, so the two scale factors are equal. */
  max-width: none;
}

.image-drop {
  border: 2px dashed var(--border);
  border-radius: var(--radius);
  padding: 1.5rem 1rem;
  text-align: center;
  background: var(--overlay-faint);
}
.image-drop.dragover {
  border-color: var(--accent);
  background: var(--tint-accent-soft);
}
.image-drop p { margin: 0 0 0.35rem; }
.image-drop p:last-of-type { margin-bottom: 0; }

/* A fixed-height window onto the picture, so a 4000px photograph does not push
   the dialog's Save button off the bottom of the screen the moment it uploads.
   vh rather than px for the same reason #block-dialog sizes its text areas that
   way: the point of the wide dialog is that it scales with the screen. */
/* ── THE CROPPER: the whole picture, with a rectangle over it ────────────────
   Two pictures in this pane, answering two questions. This one is "which part";
   .image-stage below is "and here is the result". guides/guide.html says why
   they cannot be the same element. */
.image-cropper {
  position: relative;
  display: inline-block;      /* shrink to the picture, so the rectangle's
                                 percentages are percentages OF the picture */
  max-width: 100%;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  /* crosshair says "drag out a rectangle" before anybody has tried. */
  cursor: crosshair;
  touch-action: none;         /* or a finger drag scrolls the dialog instead */
  user-select: none;
}
.image-cropper img {
  display: block;
  max-width: 100%; max-height: 30vh;
  width: auto; height: auto;
}
.image-cropbox {
  position: absolute;
  border: 1px solid var(--accent);
  cursor: move;
  /* Everything OUTSIDE the rectangle, dimmed, with one shadow rather than four
     positioned panels. It works because the cropper clips it. */
  box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
}
.image-handle {
  position: absolute;
  width: 0.75rem; height: 0.75rem;
  background: var(--accent);
  border-radius: 2px;
}
.image-handle[data-handle="nw"] { left: -0.4rem; top: -0.4rem; cursor: nwse-resize; }
.image-handle[data-handle="ne"] { right: -0.4rem; top: -0.4rem; cursor: nesw-resize; }
.image-handle[data-handle="sw"] { left: -0.4rem; bottom: -0.4rem; cursor: nesw-resize; }
.image-handle[data-handle="se"] { right: -0.4rem; bottom: -0.4rem; cursor: nwse-resize; }

/* ── THE STAGE: the block itself ──
   A fixed-height window so the dialog does not grow with the picture. What is
   inside it is a real `.guide-image`, drawn by the block's own rules. */
.image-stage {
  height: 32vh;
  overflow: hidden;
  display: flex; align-items: center; justify-content: center;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--overlay-soft);
}
.image-stage .guide-image { max-width: 100%; max-height: 100%; background: none; }
/* A cropped frame takes its width from its aspect ratio, so it is the HEIGHT
   that is pinned to the window. Uncropped, the image sizes itself as before. */
.image-stage .guide-image.is-cropped { height: 100%; width: auto; max-width: 100%; }
.image-stage img {
  max-width: 100%; max-height: 100%;
  transform-origin: center center;
}
/* Except inside a cropped frame, where the image is deliberately over-sized so
   that the chosen rectangle fills the box — capping it would undo the crop. */
.image-stage .guide-image.is-cropped img { max-width: none; max-height: none; }

/* Label, slider, label, slider — two columns, so the three controls line up and
   the labels do not each take a row of their own. */
.image-crop {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.4rem 0.6rem;
  align-items: center;
  margin-top: 0.6rem;
  max-width: 32rem;
}
.image-crop label { margin: 0; }
.image-crop input[type="range"] { width: 100%; }

.notes-toolbar-sep {
  width: 1px; align-self: stretch; margin: 0 0.15rem;
  background: var(--border);
}
.notes-toolbar-select {
  padding: 0.1rem 0.3rem; font-size: 0.82rem;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--bg); color: var(--text);
}
/* A pressed toolbar button, so the caret sitting inside something bold is
   visible rather than guessed at. notes_editor.js sets both the class and
   aria-pressed; the class is what is styled, the attribute is what is read
   out. */
.notes-toolbar .linkish.active {
  background: var(--tint-accent-strong);
  color: var(--text);
}

/* ── THE RICH TEXT VOCABULARY ────────────────────────────────────────────────
   Every class portal/pcw_portal/richtext.py allows, and nothing else — a class
   with no rule here is a size somebody picked and cannot see, and a rule here
   with no entry there is a rule the sanitiser deletes before it is ever
   applied. The two lists are one design; change them together.

   Applied to BOTH the editor and the rendered block from one selector list, so
   what is being written and what will be read cannot drift apart.

   Sizes are relative (em), not absolute. That is what lets the guide page, the
   performance view and perform.js's fit-to-screen scaling all resize a note
   without any of them knowing this vocabulary exists — and it is why a note
   written "Large" stays proportionally large on a phone. */
.guide-notes .rt-fs-1, .notes-editor .rt-fs-1, .chordpro .rt-fs-1 { font-size: 0.7em; }
.guide-notes .rt-fs-2, .notes-editor .rt-fs-2, .chordpro .rt-fs-2 { font-size: 0.85em; }
.guide-notes .rt-fs-3, .notes-editor .rt-fs-3, .chordpro .rt-fs-3 { font-size: 1em; }
.guide-notes .rt-fs-4, .notes-editor .rt-fs-4, .chordpro .rt-fs-4 { font-size: 1.2em; }
.guide-notes .rt-fs-5, .notes-editor .rt-fs-5, .chordpro .rt-fs-5 { font-size: 1.5em; }
.guide-notes .rt-fs-6, .notes-editor .rt-fs-6, .chordpro .rt-fs-6 { font-size: 2em; }
.guide-notes .rt-fs-7, .notes-editor .rt-fs-7, .chordpro .rt-fs-7 { font-size: 2.6em; }

/* System stacks only. A web font is a network request a music stand may not be
   able to make, and a face that fails to load silently re-flows a chart
   somebody is reading mid-song. */
.guide-notes .rt-ff-sans, .notes-editor .rt-ff-sans, .chordpro .rt-ff-sans {
  font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
}
.guide-notes .rt-ff-serif, .notes-editor .rt-ff-serif, .chordpro .rt-ff-serif {
  font-family: Georgia, "Times New Roman", Times, serif;
}
.guide-notes .rt-ff-mono, .notes-editor .rt-ff-mono, .chordpro .rt-ff-mono {
  font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace;
  /* Same reason the block-level switch turns them off: a coding face rewriting
     -- into an em dash turns a tab stave into something that is not one. */
  font-variant-ligatures: none;
}
.guide-notes .rt-ff-hand, .notes-editor .rt-ff-hand, .chordpro .rt-ff-hand {
  font-family: "Segoe Script", "Bradley Hand", "Brush Script MT", cursive;
}

.guide-notes .rt-ta-left, .notes-editor .rt-ta-left, .chordpro .rt-ta-left { text-align: left; }
.guide-notes .rt-ta-center, .notes-editor .rt-ta-center, .chordpro .rt-ta-center { text-align: center; }
.guide-notes .rt-ta-right, .notes-editor .rt-ta-right, .chordpro .rt-ta-right { text-align: right; }

/* The tags the toolbar can now produce that had no rule of their own. mark is
   drawn from theme tokens rather than a stored colour, which is the whole
   reason highlighting is a tag here and not a colour the writer picks: it stays
   legible in both themes. */
.guide-notes mark, .notes-editor mark, .chordpro .rt-mark {
  background: var(--tint-warning-strong);
  color: inherit;
  padding: 0 0.15em; border-radius: 2px;
}
.guide-notes hr, .notes-editor hr, .chordpro .cp-rule {
  border: 0; border-top: 1px solid var(--border); margin: 0.7rem 0;
}

/* ── FORMATTING IN A WORDS-AND-CHORDS BLOCK ──
   The same toolbar as a note, drawn from what chordpro.py makes of it. The
   inline tags arrive as CLASSES, because a run of words is drawn as one span
   however many tags it sat inside; the line-level ones — a heading, a quote, a
   list item — arrive as the line's kind. Sizes stay in em for the reason given
   above: perform.js scales the whole block by setting one font size. */
.chordpro .rt-b { font-weight: 700; }
.chordpro .rt-i { font-style: italic; }
.chordpro .rt-u { text-decoration: underline; }
.chordpro .rt-s { text-decoration: line-through; }
.chordpro .rt-u.rt-s { text-decoration: underline line-through; }
.chordpro .rt-code {
  font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace;
  font-variant-ligatures: none; font-size: 0.9em;
}
.chordpro .rt-link { color: var(--accent); }
.chordpro .cp-kind-h2 { font-size: 1.3em; font-weight: 700; }
.chordpro .cp-kind-h3 { font-size: 1.15em; font-weight: 700; }
.chordpro .cp-kind-h4 { font-size: 0.9em; font-weight: 700; letter-spacing: 0.04em; }
.chordpro .cp-kind-blockquote {
  padding-left: 0.75rem; border-left: 2px solid var(--border); color: var(--muted);
}
.chordpro .cp-kind-pre {
  font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace;
  font-variant-ligatures: none;
}
.chordpro .cp-kind-li { padding-left: 1.3rem; }
.cp-marker {
  display: inline-block; vertical-align: bottom;
  width: 1.3rem; margin-left: -1.3rem;
  color: var(--muted);
}

/* The words-and-chords editor is a notes editor that keeps its spaces — a
   chord line typed above a lyric, or tab pasted in, is only right if every
   space stays where it was put — and follows the block's fixed-width switch
   while it is being written, so what is typed is what will be read. */
.notes-editor.is-chords { white-space: pre-wrap; tab-size: 4; }
/* pre-wrap under the switch on a NOTE too, because that is how the rendered
   note is drawn with it on (.guide-block.is-monospace .guide-notes) — an
   editor that collapsed the spaces the block will show would be lying about
   where the columns are. */
.notes-editor.is-monospace {
  font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace;
  font-variant-ligatures: none;
  white-space: pre-wrap;
}
/* A contenteditable has no placeholder of its own. */
.notes-editor[data-placeholder]:empty::before {
  content: attr(data-placeholder);
  color: var(--placeholder);
  pointer-events: none;
}
.guide-notes h2, .notes-editor h2 { margin: 0.75rem 0 0.3rem; font-size: 1.1em; }
.guide-notes pre, .notes-editor pre {
  margin: 0 0 0.5rem; white-space: pre-wrap;
  font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace;
}
.notes-editor blockquote {
  margin: 0 0 0.5rem; padding-left: 0.75rem;
  border-left: 2px solid var(--border); color: var(--muted);
}
.notes-editor h2, .notes-editor h3, .notes-editor h4 { margin: 0.75rem 0 0.3rem; }
.notes-editor a { color: var(--accent); }

.request-queue { margin: 1.5rem 0; }
.request-queue h2 { font-size: 1rem; }

/* ── Tablature ──────────────────────────────────────────────────────────────
   A stave scrolls sideways rather than wrapping. Wrapping tab is worse than
   scrolling it: a stave that breaks mid-bar stops being readable at all,
   because the whole format depends on the columns lining up down the strings. */
.tabview {
  font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace;
  font-size: 0.9rem; line-height: 1.35;
  overflow-x: auto;
  padding: 0.4rem 0;
  /* Everything the engraving draws in, so a theme sets two values rather than
     twelve rules. The string line is deliberately quieter than the numbers —
     on paper it is a hairline and the frets are the ink.
     --border is not the answer here — it is tuned to separate two panels, and
     at hairline weight on a dark ground it disappears.

     ── THE DOUBLE DECLARATION BELOW IS NOT A WORKING FALLBACK ──

     It reads like one, and on a normal property it would be: declare the plain
     value, then the fancy one, and an engine that cannot parse the second keeps
     the first. ON A CUSTOM PROPERTY IT DOES NOTHING. A `--x` value is not
     validated when it is parsed — any token sequence is valid — so the
     color-mix() line always wins, and an engine without color-mix() does not
     find out until it substitutes the value into linear-gradient() below, at
     which point the whole gradient is invalid and the string line is not drawn
     at all.

     That is exactly what happened to the PDF export, where the staves came out
     as bare fret numbers. static/css/pdf.css restates --tab-line as a flat
     colour for that reason and explains it at length. The plain line is kept
     here because it costs nothing and documents the intent, but do not rely on
     it: if this ever needs a real fallback, it has to be on a property that is
     validated at parse time. */
  --tab-line: var(--muted);
  --tab-line: color-mix(in srgb, var(--muted) 58%, transparent);
  --tab-ink: var(--accent);
  /* The headroom a row gives its marks. Zero unless the stave has any, because
     an empty band above every string is the difference between five staves on
     a music stand and four. Overridden by .has-marks below. */
  --tab-marks-h: 0em;
  /* How thick the string line is. In `em` so it scales with the block when the
     performance view shrinks a wide stave, rather than staying 1px and turning
     into the heaviest thing on the page. */
  --tab-rule: 0.075em;
}
/* NO white-space: pre on the container. It was here, and it turned the
   template's OWN newlines and indentation — the whitespace between one
   <div class="tabview-line"> and the next — into rendered blank lines, so every
   string sat a full line apart. Every cell is its own element now and the
   template controls the whitespace between them, so `pre` is not needed
   anywhere: guides/_tab.html strips the newlines at the source instead. */
.tabview-line { display: flex; align-items: stretch; }
/* The label stays put while the frets scroll — the one thing a reader needs
   when a stave is wider than the screen is to still know which string it is. */
.tabview-label {
  position: sticky; left: 0; z-index: 2;
  flex: none; width: 1.5rem;
  background: var(--surface);
  color: var(--tab-ink); font-weight: 700;
  /* Right-aligned so the label sits against its own bar line and "e|" reads as
     one unit, the way it does in the source text. Left-aligned in a fixed box
     it floated a character away from the pipe. 1.5rem holds a two-character
     label — F#, Bb — without pushing the frets out of line between strings. */
  text-align: right;
  /* Aligned to the CHARACTER band rather than to the whole row, so the label
     does not drift upward into the marks band on a stave that has one. */
  padding-top: var(--tab-marks-h);
}

/* ── One string, and the band above it ──
   flex: none so the run keeps its natural width inside the flex line and the
   container scrolls, rather than being squeezed to fit. */
.tabview-run { position: relative; flex: none; padding-top: var(--tab-marks-h); }
.tabview-cells { display: block; }
.tabview-marks {
  position: absolute; top: 0; left: 0; right: 0;
  height: var(--tab-marks-h);
  /* A bend label sits at the tip of its arrow and is allowed past the end of
     the mark it belongs to. Clipping it would hide the one word — "full" — that
     says how far the bend goes. */
  overflow: visible;
  color: var(--tab-ink);
}
/* A stave with any articulation in it gives EVERY row the same headroom.
   Per stave rather than per line, because rows of different heights break the
   bar lines that run down between them. */
.tabstave.has-marks .tabview { --tab-marks-h: 1.15em; }

/* ── The cells ──
   inline-block and monospace, so a cell is exactly as many characters wide as
   the text it came from and the columns line up down the strings.

   The explicit height is not decoration. A rest carries NO TEXT — the string
   line is drawn on it — and an empty inline-block is zero pixels tall, so
   without this the line would not exist and every row would collapse to the
   height of whichever cell happened to have a number in it. */
.tab-cell {
  display: inline-block;
  vertical-align: top;
  position: relative;
  white-space: pre;
  height: 1.35em;
  line-height: 1.35em;
}
.tab-fret { color: var(--text); font-weight: 600; }
.tab-other { color: var(--muted); }

/* THE STRING LINE, drawn only where a dash was.
   Not one rule behind the whole row with the numbers knocking a hole in it:
   that needs every printed cell to carry an opaque background matching whatever
   is behind the block, which is a different colour on the editor, on a music
   stand and inside the library preview. Drawing the line ON the rests puts it
   exactly where the dashes were and leaves the gaps the frets already occupy —
   which is also what a printed stave looks like. */
.tab-rest {
  background-image: linear-gradient(
    to bottom,
    transparent calc(50% - var(--tab-rule) / 2),
    var(--tab-line) calc(50% - var(--tab-rule) / 2),
    var(--tab-line) calc(50% + var(--tab-rule) / 2),
    transparent calc(50% + var(--tab-rule) / 2));
}

/* THE BAR LINE, spanning the whole row INCLUDING its marks band, so the rules
   on consecutive strings meet and read as one bar line down the stave. Without
   the reach up through the band, a stave with any articulation in it gets a bar
   line dashed into six pieces with a gap at every string. */
.tab-bar { width: 1ch; }
.tab-bar::before {
  content: "";
  position: absolute;
  left: calc(50% - var(--tab-rule) / 2);
  width: var(--tab-rule);
  top: calc(-1 * var(--tab-marks-h));
  bottom: 0;
  background: var(--tab-line);
}
/* Except on the TOP string, where there is no gap above to close and the reach
   would leave a bar line sticking up out of the stave. On paper a bar line runs
   from the top string to the bottom string and stops. */
.tabview-line:first-child .tab-bar::before { top: 0; }

/* A slide, and a muted note. Both keep their own column — a slide is a
   connection between two notes and belongs exactly where it was written. */
.tab-slide-up, .tab-slide-down, .tab-mute { width: 1ch; }
.tab-slide-up svg, .tab-slide-down svg, .tab-mute svg {
  display: block; width: 100%; height: 1.35em;
  fill: none; stroke: currentColor; stroke-width: 1.4;
  stroke-linecap: round;
}
.tab-slide-up, .tab-slide-down { color: var(--tab-ink); }
.tab-mute { color: var(--text); }
.tab-mute svg { stroke-width: 1.6; }

/* ── The marks above the stave ──
   The band is 1.15em and a mark is 0.62em of it, which leaves room for the
   letter over a slur or the amount at the tip of a bend to sit ABOVE the curve
   without climbing into the string above. Those three numbers are one set —
   raising the curve without lowering the label puts "full" through the frets of
   the previous string. */
.tabmark { position: absolute; bottom: 0; height: 0.62em; }
.tabmark-curve {
  display: block; width: 100%; height: 100%;
  fill: none; stroke: currentColor; stroke-width: 1.4;
  stroke-linecap: round;
}
/* The arrowhead is its own square box pinned to the end of the curve, NOT part
   of the stretched SVG — a triangle stretched over eight columns stops being a
   triangle, which is what the first version drew. */
.tabmark-head {
  position: absolute; right: -0.12em; top: -0.1em;
  width: 0.42em; height: 0.42em;
  background: currentColor;
  clip-path: polygon(50% 0, 100% 100%, 0 100%);
}
.tabmark-release .tabmark-head { top: auto; bottom: -0.1em; transform: rotate(180deg); }
/* Vibrato is TILED rather than stretched, so eight columns of it is eight
   shakes. A mask over currentColor rather than a background image, because a
   colour baked into a data URI cannot follow the theme. */
.tabmark-wave {
  position: absolute; inset: 0;
  background: currentColor;
  -webkit-mask-repeat: repeat-x; mask-repeat: repeat-x;
  -webkit-mask-size: 0.62em 0.42em; mask-size: 0.62em 0.42em;
  -webkit-mask-position: left bottom; mask-position: left bottom;
  -webkit-mask-image: var(--tab-wave); mask-image: var(--tab-wave);
}
:root {
  --tab-wave: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 6'%3E%3Cpath d='M0 5 L3 1 L6 5 L9 1 L12 5' fill='none' stroke='black' stroke-width='1.6' stroke-linecap='round'/%3E%3C/svg%3E");
}
/* The letter over a slur — "H" for a hammer-on, "P" for a pull-off — and the
   amount at the tip of a bend arrow. Both are what a printed book puts there. */
.tabmark-label {
  position: absolute; bottom: 0.6em;
  font-style: normal; font-weight: 700;
  font-size: 0.55em; line-height: 1;
  letter-spacing: 0;
  white-space: nowrap;
}
.tabmark-slur .tabmark-label { left: 50%; transform: translateX(-50%); }
.tabmark-bend .tabmark-label, .tabmark-release .tabmark-label { left: 100%; margin-left: 0.2em; }

/* ── The notes around a stave ──
   "Repeat 3 times" off to the right, a cue on the left, a line of the lyric
   underneath. Four positions because those are the four a printed book uses. */
.tabstave { margin: 0.5rem 0; }
.tabstave-row { display: flex; align-items: center; gap: 0.6rem; }
/* The stave keeps the sideways scroll; the notes beside it do not shrink and do
   not take a share of the scrolling.

   flex-grow 0, not 1. Growing let the stave claim the whole row, so a note on
   the RIGHT was pushed to the far edge of the screen — on a performance view,
   a hand-width away from the bar it belongs to. At its own width the stave
   ends where its last bar line does and the note sits just past it; it still
   shrinks (and scrolls, or is scaled by perform.js) when it is too wide. */
.tabstave-row .tabview { flex: 0 1 auto; min-width: 0; }
.tabstave-side { flex: none; max-width: 11rem; }
.tabstave-note {
  margin: 0; color: var(--muted);
  font-size: 0.85rem; line-height: 1.3;
}
.tabstave-side .tabstave-note { font-style: italic; }
.tabstave-right { text-align: left; }
.tabstave-above { margin-bottom: 0.15rem; }
.tabstave-below { margin-top: 0.15rem; }
/* Indented to the string labels, so a lyric written under a stave sits under
   the frets rather than under the label column. */
.tabstave-above, .tabstave-below { padding-left: 1.5rem; }

/* On a phone the stave is the thing being read and a note beside it must not
   take a third of the width off it. Narrowed rather than moved: "x4" belongs to
   the RIGHT of the stave, and putting it above would say something else. */
@media (max-width: 40rem) {
  .tabstave-side { max-width: 5rem; }
  .tabstave-row { gap: 0.4rem; }
}

.tabview-note { margin: 0.3rem 0; color: var(--muted); font-size: 0.85rem; }

/* ── The tab editor ── */
.tabmode { display: flex; gap: 0; margin-bottom: 0.6rem; border-bottom: 1px solid var(--border); }
.tabmode-btn {
  width: auto; margin: 0; padding: 0.4rem 0.9rem;
  background: none; color: var(--muted);
  border: 0; border-bottom: 2px solid transparent;
  font-size: 0.88rem; cursor: pointer;
}
.tabmode-btn:hover { background: var(--overlay); color: var(--text); }
.tabmode-btn.active { color: var(--accent); border-bottom-color: var(--accent); background: none; }

.tabgrid-toolbar {
  display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
  margin-bottom: 0.5rem; font-size: 0.85rem;
}
.tabgrid-toolbar label { margin: 0; color: var(--muted); }
.tabgrid-strings { width: 4rem; margin: 0; }
.tabgrid-toolbar-right { margin-left: auto; display: flex; gap: 0.75rem; }

.tabgrid-groups { display: flex; gap: 0.3rem; margin-bottom: 0.4rem; }
.tabgrid-group {
  width: auto; margin: 0; padding: 0.2rem 0.6rem;
  background: none; color: var(--muted);
  border: 1px solid var(--border); border-radius: 6px; font-size: 0.8rem;
}
.tabgrid-group.active { color: var(--accent); border-color: var(--accent); background: var(--tint-accent-soft); }

/* The four boxes for what sits around the stave. Laid out as a row that wraps
   rather than a grid, so on a phone they stack in the order they are read. */
.tabgrid-notes {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: 0.4rem 0.75rem; margin-bottom: 0.6rem;
}
.tabgrid-notes-label { color: var(--muted); font-size: 0.8rem; }
.tabgrid-note-field {
  display: flex; align-items: center; gap: 0.35rem;
  margin: 0; flex: 1 1 9rem; min-width: 0;
}
.tabgrid-note-field > span { color: var(--muted); font-size: 0.8rem; flex: none; }
.tabgrid-note { margin: 0; padding: 0.25rem 0.4rem; font-size: 0.85rem; min-width: 0; }

.tabgrid-scroll { overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); }
.tabgrid-table { border-collapse: collapse; font-family: ui-monospace, "Consolas", monospace; }
.tabgrid-table:focus { outline: 2px solid var(--accent); outline-offset: -2px; }
.tabgrid-table th { padding: 0; background: var(--surface); position: sticky; left: 0; }
.tabgrid-label {
  width: 2.2rem; margin: 0; padding: 0.15rem 0.25rem;
  background: transparent; border: 0; border-right: 1px solid var(--border);
  color: var(--accent); font-weight: 700; text-align: center; font-size: 0.85rem;
}
.tabgrid-cell {
  width: 1.6rem; height: 1.6rem; padding: 0;
  border: 1px solid var(--border);
  text-align: center; font-size: 0.85rem; cursor: pointer;
  user-select: none;
}
.tabgrid-cell:hover { background: var(--overlay-strong); }
.tabgrid-cell.active { background: var(--tint-accent-strong); outline: 1px solid var(--accent); }
/* A bar line is structure, not a note — it reads as a rule down the stave. */
.tabgrid-bar { background: var(--overlay-bold); color: var(--accent); font-weight: 700; }

/* ── The tab entry aid ───────────────────────────────────────────────────────
   Keypad, fretboard or nothing. It exists because the grid takes its input from
   a keydown handler on the table, and tapping a table cell on a phone raises no
   keyboard — so without an aid the grid is readable and unfillable on the device
   most likely to be on a music stand. static/js/tabgrid.js has the argument. */
.tabaid { margin-top: 0.75rem; }

.tabaid-modes {
  display: flex; align-items: center; gap: 0.3rem; flex-wrap: wrap;
  margin-bottom: 0.5rem;
}
.tabaid-modes-label {
  color: var(--muted); font-size: 0.78rem;
  text-transform: uppercase; letter-spacing: 0.06em; margin-right: 0.3rem;
}
.tabaid-mode {
  width: auto; margin: 0; padding: 0.2rem 0.7rem;
  background: none; color: var(--muted);
  border: 1px solid var(--border); border-radius: 6px;
  font-size: 0.8rem; cursor: pointer;
}
.tabaid-mode:hover { background: var(--overlay); color: var(--text); }
.tabaid-mode.active {
  color: var(--accent); border-color: var(--accent); background: var(--tint-accent-soft);
}

.tabaid-body {
  padding: 0.6rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

/* Where you are and how to move. Present with either aid: neither is usable
   without a way to reach the next column that is not "find the right cell in a
   grid that may be scrolled off screen". */
.tabaid-nav {
  display: flex; align-items: center; gap: 0.35rem; flex-wrap: wrap;
  margin-bottom: 0.6rem;
}
.tabaid-where { color: var(--muted); font-size: 0.8rem; margin-left: 0.4rem; }

.tabaid-keys { display: flex; flex-wrap: wrap; gap: 0.35rem; }

/* 2.75rem is the point of this rule, not the look of it: it clears the 44px
   minimum touch target on every phone this will be used on. A keypad whose keys
   need aiming at is a keypad that has not solved the problem it was added for. */
.tabaid-key {
  width: 2.75rem; height: 2.75rem; margin: 0; padding: 0;
  background: var(--surface); color: var(--text);
  border: 1px solid var(--border); border-radius: 8px;
  font-size: 1rem; font-weight: 600; cursor: pointer;
}
.tabaid-key:hover { background: var(--overlay-strong); border-color: var(--accent); }
.tabaid-key:active { background: var(--tint-accent-strong); }
.tabaid-key-wide { width: auto; padding: 0 0.9rem; font-size: 0.85rem; }

/* The Special tab's keys: the mark, and what it is called under it. Wider than
   a digit key because "Release a bend" is a phrase, and still at least the
   44px target in both directions. */
.tabaid-key-symbol {
  width: auto; min-width: 2.75rem; height: auto; min-height: 2.75rem;
  padding: 0.25rem 0.6rem;
  display: inline-flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 0.05rem;
}
.tabaid-key-glyph {
  font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace;
  font-size: 1.05rem; line-height: 1.1;
}
.tabaid-key-name { font-size: 0.68rem; font-weight: 500; color: var(--muted); }

/* The fretboard's two switches: where the cursor goes after a note. */
.tabaid-switches {
  display: flex; flex-wrap: wrap; gap: 0.3rem 1.25rem;
  margin-bottom: 0.6rem;
}
.tabaid-switch {
  display: inline-flex; align-items: center; gap: 0.35rem;
  margin: 0; font-size: 0.85rem; color: var(--text); cursor: pointer;
}
.tabaid-switch input { width: auto; margin: 0; }

/* ── THE FRETBOARD ──
   Drawn as a neck, because the question this aid answers is "where does my
   finger go" and a player finds the seventh fret by its dot long before they
   count to seven: rosewood, a bone nut, fret wire, strings heavier toward the
   bass, and the inlays at 3, 5, 7, 9 and a double at 12.

   A picture, so it keeps its colours in both themes the way a photograph
   would. The open strings sit off the wood, on the page's own ground — which is
   where they are on a guitar, above the nut.

   It scrolls sideways rather than shrinking its frets: a fret too small to hit
   is no more use than no fretboard, and mis-tapping the 9th when you meant the
   10th is a wrong note in a guide somebody else will play. tabgrid.js sets the
   column widths — narrowing toward the body, like a neck, and never under
   2.6rem. */
.tabaid-fretboard-scroll { overflow-x: auto; padding-bottom: 0.25rem; }
.fretboard {
  display: grid;
  width: max-content;
  --fb-wood: #3b2416;
  --fb-wood-edge: #52321d;
  --fb-fret: #c7cad0;
  --fb-nut: #efe6cf;
  --fb-string: #ddd4bd;
  --fb-inlay: #efe8d6;
}
.fretboard-num {
  align-self: end; text-align: center; padding-bottom: 0.2rem;
  color: var(--muted); font-size: 0.7rem; font-weight: 600;
  font-variant-numeric: tabular-nums;
}
.fretboard-num.is-inlay { color: var(--text); }
/* The string names stay put while the neck scrolls under them — the same
   courtesy the stave's own labels get. */
.fretboard-string {
  grid-column: 1;
  position: sticky; left: 0; z-index: 4;
  display: flex; align-items: center; justify-content: flex-end;
  padding-right: 0.4rem;
  background: var(--bg); color: var(--accent);
  font-size: 0.8rem; font-weight: 700;
}
.fretboard-wood {
  background: linear-gradient(to bottom,
    var(--fb-wood-edge), var(--fb-wood) 12%, var(--fb-wood) 88%, var(--fb-wood-edge));
  border-left: 0.35rem solid var(--fb-nut);
  border-radius: 0 3px 3px 0;
}
.fretboard-wire { border-right: 3px solid var(--fb-fret); pointer-events: none; }
.fretboard-inlay {
  display: flex; flex-direction: column;
  align-items: center; justify-content: space-evenly;
  pointer-events: none;
}
.fretboard-dot {
  width: 0.7rem; height: 0.7rem; border-radius: 50%;
  background: var(--fb-inlay);
}
/* One string at one fret. Transparent but for the string itself, drawn through
   the middle at this row's gauge, so the wood, the wire and the inlays behind
   show through — strings run OVER the dots, as they do. */
.fretboard-fret {
  position: relative; z-index: 2;
  width: 100%; height: 100%; min-height: 0;
  margin: 0; padding: 0;
  border: 0; border-radius: 0;
  background: linear-gradient(to bottom,
    transparent calc(50% - var(--gauge) / 2),
    var(--fb-string) calc(50% - var(--gauge) / 2),
    var(--fb-string) calc(50% + var(--gauge) / 2),
    transparent calc(50% + var(--gauge) / 2));
  cursor: pointer;
}
.fretboard-fret:hover { background-color: transparent; }
.fretboard-fret:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
/* Above the nut the strings cross the page's own ground, so they take a colour
   that holds up on it in both themes. */
.fretboard-fret.is-open { --fb-string: var(--muted); }
/* The finger: a hint under the pointer, and solid for every note already in
   the cursor's column — so a chord being entered shows as its shape. */
.fretboard-note {
  position: absolute; left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  width: 1.55rem; height: 1.55rem; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-size: 0.75rem; font-weight: 700; line-height: 1;
  background: var(--accent); color: var(--accent-ink);
  opacity: 0; pointer-events: none;
}
.fretboard-fret:hover .fretboard-note,
.fretboard-fret:focus-visible .fretboard-note { opacity: 0.6; }
.fretboard-fret.is-on .fretboard-note { opacity: 1; box-shadow: 0 0 0 2px var(--fb-nut); }

#block-tab {
  font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace;
  font-size: 0.9rem;
  white-space: pre;      /* tab lines must not wrap while being typed either */
  overflow-x: auto;
}
#block-dialog #block-tab { width: 90%; min-height: 40vh; }

/* ── Scale diagrams ─────────────────────────────────────────────────────────
   Every colour is a token, so one drawing serves both themes. The old
   implementation sniffed the page for a light-mode class and branched inside
   the SVG generator, which meant a picture that could be wrong about its own
   surroundings — and would have needed teaching about a third theme. */
.sd-figure { margin: 0.4rem 0 0; }
.sd { max-width: 100%; height: auto; display: block; }
.sd-caption { margin: 0.3rem 0 0; color: var(--muted); font-size: 0.78rem; }

.sd-board  { fill: var(--overlay-strong); }
/* Fret wire in --muted, a TEXT colour, at 3px. It was --border at 1px, and
   --border is tuned to separate two panels — at a hairline on the board's own
   tint it all but vanished, so a scale read as dots floating on a grey bar with
   nothing saying which fret each one was on. Asked for 2026-09-13. */
.sd-fret   { stroke: var(--muted); stroke-width: 3; }
/* The nut is drawn only where the diagram genuinely starts at the top of the
   neck, so it reads as the end of the fretboard rather than decoration — and
   it stays heavier than a fret, or the frets would now match it. */
.sd-nut    { stroke: var(--text); stroke-width: 5; }
.sd-string { stroke: var(--muted); }
.sd-marker { fill: var(--muted); opacity: 0.35; }

.sd-dot  { fill: var(--tint-accent-strong); stroke: var(--accent); stroke-width: 1; }
/* The root is the note the shape is built from, so it is the one thing that has
   to be findable at a glance — filled rather than outlined, and a size larger. */
.sd-root { fill: var(--accent); stroke: none; }
.sd-notelabel {
  font: 600 10px system-ui, sans-serif; fill: var(--text);
  text-anchor: middle; dominant-baseline: central;
}
.sd-root + .sd-notelabel { fill: var(--accent-ink); }
/* A mode's special note (scalediagram.SPECIAL_STEPS, 2026-09-21): harmonic
   minor's raised 7th, the blue note, a diatonic mode's characteristic note.
   Filled in its own colour so it stands apart from both the root and the
   rest; song guides draw it too. */
.sd-special { fill: var(--special); stroke: none; }
.sd-special + .sd-notelabel { fill: #fff; }
.sd-fretnum {
  font: 600 11px system-ui, sans-serif; fill: var(--muted); text-anchor: middle;
}
.sd-stringlabel {
  font: 600 12px system-ui, sans-serif; fill: var(--accent);
  text-anchor: end; dominant-baseline: central;
}

/* The editor's pickers and preview. */
.scale-fields { display: flex; flex-wrap: wrap; gap: 0.75rem; margin-bottom: 0.75rem; }
.scale-fields .field { min-width: 7rem; }
.scale-fields .field-wide { min-width: 13rem; }
.sd-preview {
  border: 1px solid var(--border); border-radius: var(--radius);
  padding: 0.5rem; overflow-x: auto; min-height: 8rem;
}

/* ── Close, rather than a breadcrumb ────────────────────────────────────────
   On the song and guide editors. "← Song library" took a whole row across the
   top of the card for somewhere people go once, at the end; this says the same
   thing in the corner every dialog-like screen puts it, and hands the row back
   to the thing being edited.

   Big enough to hit: 2.25rem square is above the 24px minimum target size, which
   a bare × glyph at font size would not be. */
.close-x {
  position: absolute; top: 0.75rem; right: 0.9rem; z-index: 2;
  display: flex; align-items: center; justify-content: center;
  width: 2.25rem; height: 2.25rem;
  border-radius: var(--radius);
  color: var(--muted); text-decoration: none;
  font-size: 1.5rem; line-height: 1;
}
.close-x:hover { background: var(--overlay); color: var(--text); }
.close-x:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Provenance at the foot of a record — who made it and when. Worth keeping and
   not worth the top of the screen. */
.record-footer {
  margin: 2rem 0 0; padding-top: 0.75rem;
  border-top: 1px solid var(--border);
}

/* A section name that opens its own editor. Styled as the name it is rather
   than as a button, because it IS the row's label — the affordance is that it
   is the only thing in the cell and it highlights on hover. */
button.section-name {
  width: auto; margin: 0; padding: 0;
  background: none; border: 0;
  color: var(--text); font: inherit; text-align: left;
  cursor: pointer;
}
button.section-name:hover { color: var(--accent-hover); text-decoration: underline; }
button.section-name:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* ---------------------------------------------------------------- setlists */
/* The setlist editor. A set is a card inside the page's card, because the
   thing being edited is a LIST OF LISTS and the nesting has to be visible —
   without a boundary, three sets of songs read as one long table with headings
   in it, and the whole point of the screen is which song is in which set. */

.setlist-set {
  border: 1px solid var(--border); border-radius: var(--radius);
  padding: 0.75rem 0.9rem; margin-bottom: 1rem;
  background: var(--overlay-faint);
}
.setlist-set-head {
  display: flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap;
}
.setlist-set-name {
  margin: 0; font-size: 1rem;
}
.setlist-set-meta {
  margin: 0; color: var(--muted); font-size: 0.82rem;
}
/* Pushed to the end of the header row rather than given a row of its own, the
   same arrangement .guide-block-actions uses. */
.setlist-set-actions { margin-left: auto; white-space: nowrap; }

/* The empty-set placeholder row. This is what gives a set with no songs enough
   height to be a drop target — see the comment in setlists/_set.html for why a
   `.setlist-songs-body:empty` rule cannot do the job (Jinja's own indentation
   leaves a text node behind, so :empty never matches). */
.setlist-empty-row td {
  padding: 0.7rem 0.5rem; font-size: 0.88rem; font-style: italic;
}

/* ── The workbench ────────────────────────────────────────────────────────────
   The library on the left, the night on the right, and songs dragged across.

   Both halves scroll on their OWN rather than the page scrolling as a whole,
   which is the entire point of the arrangement: building a set is comparing
   what you have against what you have chosen, and losing sight of one while
   reading the other defeats it. Same argument the sets make for never being
   paginated — you cannot drag between two things unless both are visible.

   The heights are viewport-relative because that is the only unit that means
   "as tall as the screen has room for". A fixed height either wastes a large
   monitor or overflows a laptop, and the panels have no natural height of their
   own to fall back on. */
.setlist-workbench {
  display: grid;
  grid-template-columns: minmax(14rem, 22rem) 1fr;
  gap: 1rem;
  align-items: start;
}

/* Under 60rem the two panels stack, and each KEEPS its own scroll rather than
   surrendering to the page. Stacked scroll panes are worse than side by side
   and better than one column holding everything, which is the honest thing a
   phone can do with a screen whose whole design is two halves at once. */
@media (max-width: 60rem) {
  .setlist-workbench { grid-template-columns: 1fr; }
}

.library-panel {
  display: flex; flex-direction: column;
  /* Matched to .sets-panel below, so the two halves of the workbench are the
     same height and each scrolls inside it. */
  max-height: min(78vh, 58rem);
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--overlay-faint);
  padding: 0.75rem 0.9rem;
}
.panel-head { display: flex; align-items: baseline; gap: 0.6rem; flex: none; }
.panel-title { margin: 0; font-size: 1rem; color: var(--text); }
.panel-meta { margin: 0 0 0 auto; color: var(--muted); font-size: 0.82rem; }

.library-controls { display: flex; flex-direction: column; gap: 0.5rem; flex: none; }
/* The panel's own field, NOT the .field / .field-wide every other form uses.
   Those carry `flex: 2 1 288px` for the horizontal .inline-form, and inside a
   flex column that basis is read as a 288-PIXEL HEIGHT — which put a blank
   quarter of the screen between the filter and the song list. Measured in the
   browser; setlists/_library.html carries the same note. */
.library-field { display: flex; flex-direction: column; gap: 0.2rem; margin-top: 0.5rem; }
.library-field label { margin: 0; font-size: 0.8rem; color: var(--muted); }
.library-field input, .library-field select { margin: 0; }

/* .check-inline is tuned for the horizontal .inline-form: it pushes itself to
   the bottom of the row with align-self and a padding-bottom that lines it up
   with the inputs beside it. In this column there is nothing beside it, so both
   have to go — otherwise the box sits right-aligned with a phantom half-line of
   space under it. Same class of mismatch as .field-wide above, and the same
   fix: state what this column needs rather than inherit a row's compromises. */
.library-controls .check-inline {
  align-self: stretch; padding-bottom: 0;
  white-space: normal; cursor: pointer;
}

/* The scrolling half of each panel. Applied to the library list and to every
   set's song list, so both sides of the screen behave the same way when they
   run out of room — a set of thirty songs scrolls exactly as the library does.

   min-height is what makes an EMPTY set a target you can aim at. The old table
   version could not have this: an empty <tbody> collapses to zero, and the
   obvious `:empty` rule never matched because Jinja leaves its own indentation
   behind as a text node between the tags. A block container has a height
   because it was given one. */
.panel-scroll {
  display: flex; flex-direction: column; gap: 0.35rem;
  overflow-y: auto;
  min-height: 4rem;
  padding: 0.35rem 0.15rem;
}
/* The library list takes whatever height the panel has left after its head and
   controls. min-height:0 is the flex item override that actually lets it
   shrink — without it a long list forces the panel past its own max-height and
   the scroll happens on the page instead, which is the arrangement this whole
   screen exists to avoid. */
#library-songs { flex: 1; min-height: 0; }
.set-songs { max-height: min(50vh, 34rem); }

/* The right-hand column scrolls as a whole too, so a setlist of six sets does
   not push the library panel's top off the screen. Its sets scroll inside it. */
.sets-panel { max-height: min(78vh, 58rem); overflow-y: auto; padding-right: 0.15rem; }

/* Where a drop would land. On the library panel this means "let go here and the
   song comes out of the setlist", which is a destructive-ish action and gets
   the accent rather than a danger colour on purpose — it is reversible by
   dragging the song straight back in. */
.drop-target {
  outline: 2px dashed var(--accent); outline-offset: -2px;
  background: var(--tint-accent-faint);
}

/* ── A song, as a block ───────────────────────────────────────────────────────
   The same object on both sides of the workbench: in the library it is
   something you could use, in a set it is a placement in the running order.
   Deliberately one shape, so dragging one across reads as one thing moving
   rather than as a tile becoming a table row.

   It replaced a six-column table on the right-hand side. The columns were
   carrying nothing — key, BPM and length are empty on most rows and read as one
   phrase where they are not, and nothing in a set is ever compared DOWN a
   column. What the table cost was the gesture: a row you can only drag up and
   down inside one table is not the same as picking a song up and putting it
   where you want it. */
.song-block {
  display: flex; align-items: center; gap: 0.6rem;
  position: relative;
  padding: 0.5rem 0.7rem;
  background: var(--bg);
  border: 1px solid var(--border); border-radius: var(--radius);
  flex: none;                    /* Never squashed by a full flex column. */
  /* The actions drop to a second line rather than squeezing the title.
     Without this a placement block on a phone came out one character per line:
     the four link-styled controls are nowrap and unshrinkable, so they took the
     row and the title — which wraps at any character — got what was left, which
     was nothing. Seen at 375px before this was added. */
  flex-wrap: wrap;
}
/* The library filter hides blocks with the `hidden` ATTRIBUTE, and the rule
   above breaks it: `hidden` works by a user-agent `display: none`, which any
   explicit display in an author stylesheet outranks. So a filtered library
   showed every song with the box full of text — visible in a screenshot and
   NOT in a check of `element.hidden`, which was true the whole time. Stated
   here rather than by filtering with a class, so anything that reaches for
   `hidden` on one of these keeps working. */
.song-block[hidden] { display: none; }

.song-block[draggable="true"] { cursor: grab; }
.song-block[draggable="true"]:hover { border-color: var(--accent); }
.song-block.dragging { opacity: 0.4; }

/* The stand-in for a library song while it is being dragged over a set. Dashed
   and dimmed because it is not there yet: what it marks is where the song WILL
   land if you let go, and drawing it as a finished block would claim the write
   has already happened. */
.song-block-ghost {
  opacity: 0.65;
  border-style: dashed; border-color: var(--accent);
  background: var(--tint-accent-faint);
  pointer-events: none;          /* Or it swallows the dragover it exists to
                                    position itself with. */
}

.song-block-pos {
  flex: none; min-width: 1.4rem;
  color: var(--muted); font-size: 0.8rem;
  font-variant-numeric: tabular-nums; text-align: right;
}
/* min-width is a floor, not the usual `0`. `0` is what lets a flex item shrink
   below its content, which is right when the content can ellipsis and wrong
   here — these titles wrap at any character, so shrinking to zero is not a
   narrower column, it is a vertical column of letters. 8rem holds a short title
   and forces the wrap above instead. */
.song-block-text {
  display: flex; flex-direction: column;
  flex: 1 1 8rem; min-width: 8rem;
}
.song-block-name { color: var(--text); font-weight: 500; overflow-wrap: anywhere; }
.song-block-sub { color: var(--muted); font-size: 0.8rem; overflow-wrap: anywhere; }

/* How many times this song is already somewhere in the setlist. Not a warning —
   a song can legitimately open the night and come back in the encore — so it is
   a quiet count rather than a badge in a state colour. */
.song-block-count {
  flex: none;
  color: var(--muted); font-size: 0.78rem;
  font-variant-numeric: tabular-nums;
}

/* Pushed to the end of the block, the same arrangement .setlist-set-actions and
   .guide-block-actions use. Held on one line: four link-styled controls that
   wrap turn a 40px block into a 70px one halfway down a list. */
.song-block-actions {
  flex: none; margin-left: auto;
  display: flex; align-items: center; gap: 0.15rem;
  white-space: nowrap;
}
.song-block-actions .grip { cursor: grab; color: var(--muted); }

/* The vibe stripe, exactly as on the library tile and for exactly the reason
   the tile has one: a set is read for SHAPE — where the heavy ones are, how
   many slow ones sit in a row — before a single title has been read. A pseudo
   element rather than a border-left, because .song-block:hover repaints
   border-color and would turn the stripe accent-coloured under the pointer,
   which is the one moment somebody is looking straight at it. */
.song-block-vibed { padding-left: 1.2rem; }
.song-block-vibed::before {
  content: ""; position: absolute; left: 0; top: 0; bottom: 0;
  width: 0.35rem;
  border-radius: var(--radius) 0 0 var(--radius);
  background: var(--vibe-color);
  /* The right edge only: the block's own border frames the other three. Same
     reasoning as the ring on .vibe-swatch. */
  box-shadow: inset -1px 0 0 var(--overlay-bold);
}

/* "Nothing in this set yet". A real element the script toggles rather than a
   CSS rule, because it has to appear and disappear mid-drag, long before the
   server answers. */
.set-empty { margin: 0; padding: 0.5rem 0.2rem; font-size: 0.85rem; font-style: italic; }

/* The per-placement note sits under the title, in the same column, so it reads
   as belonging to this song IN THIS SET rather than to the song. */
.setlist-song-note { display: block; font-style: italic; }

/* ── The performance view ─────────────────────────────────────────────────────
   One screen, whatever you are playing from: a bar that never moves and a chart
   that scrolls under it. It replaced two — a single guide's view and a
   setlist's — which drew the same blocks and differed only in what sat above
   them.

   ── IT TAKES THE WHOLE WINDOW, AND THE TOP BAR IS NOT THERE ──

   perform.html overrides base.html's `chrome` block with nothing, so the
   Portal's menu row is absent rather than hidden. This is the only screen that
   does it. It is read from a music stand at arm's length: every row above the
   chart is width taken off a tab stave, which the fitter then pays for by
   scaling the stave down — and five navigation controls hanging over a surface
   whose whole design goal is that nothing can be pressed by mistake is the
   opposite of what it is for.

   body.performing is the same device as body.centred further up — a layout mode
   a page opts into, rather than a set of overrides scattered through the rules
   for the things inside it. */
body.performing {
  /* Not min-height. The page must be EXACTLY the window, or the shell below
     computes its height from a body that has already grown to fit the chart,
     and the bar leaves the screen at the bottom of a long song. */
  height: 100vh;
  /* That unit is wrong by the height of a phone's address bar, which collapses
     as you scroll. dvh is right and is not everywhere yet; declared second so
     it wins where it is understood and is ignored where it is not. */
  height: 100dvh;
  overflow: hidden;
}
body.performing main {
  height: 100%;
  padding: 0;
}

/* The bar, and the region that scrolls under it.

   Done with a flex column and overflow on the child rather than position:
   sticky. Sticky depends on which ancestor establishes the scrolling context,
   which differs between browsers once a body, a main and a card are involved,
   and it fails by quietly not sticking. A height and an overflow do not. */
.perform-shell {
  display: flex; flex-direction: column;
  height: 100%;
  background: var(--surface);
}

/* ── THE BAR KEEPS CLEAR OF THE HARDWARE ──
   This is the only screen that fills the window edge to edge, so it is the only
   one that can put a control under a status bar, a notch, or the rounded corner
   of a tablet. perform.html asks for `viewport-fit=cover` precisely so
   env(safe-area-inset-*) reports what to keep clear of, and these three
   declarations are the other half of that bargain.

   max(), not addition. The insets already include the room the platform wants
   above the content, so adding the bar's own padding on top of a 47px inset
   would push it down by half an inch again. What is wanted is "the larger of
   what this design needs and what the device needs", which is what max() says.

   The base of 2.5rem is the plain answer to the plain problem: on a tablet the
   bar sat hard against the top of the viewport with nothing between it and the
   browser's own furniture, and 0.85rem — the first attempt at this — was still
   not enough room on an iPad. Forty pixels is roughly the height of Safari's
   own top bar, which is the thing it has to clear.

   It is unconditional rather than behind a media query on purpose. There is no
   query that asks "is a browser toolbar overlapping me", the screen is designed
   to be read from a stand at arm's length where a little air at the top costs
   nothing, and a bar that is in a different place on a laptop and a tablet is a
   bar somebody has to find twice.

   Every inset is 0px on a desktop browser and on a viewport that is not
   `cover`, so there max() resolves to this base. */
.perform-bar {
  display: flex; align-items: stretch; gap: 0.6rem;
  flex: none;
  padding-top: max(2.5rem, env(safe-area-inset-top, 0px));
  padding-bottom: 0.5rem;
  padding-left: max(clamp(0.5rem, 1.5vw, 1rem), env(safe-area-inset-left, 0px));
  padding-right: max(clamp(0.5rem, 1.5vw, 1rem), env(safe-area-inset-right, 0px));
  border-bottom: 1px solid var(--border);
  background: var(--overlay-faint);
}

/* ── THE TWO TILES ────────────────────────────────────────────────────────────
   What you are playing from, and which song you are on, as the same objects the
   setlist library and the song library draw — same box, same vibe stripe down
   the left edge. That is the whole idea: the thing that names where you are
   looks like the thing you picked it out of, so a colour means the same in the
   bar as it does in the library.

   They are <button>s, because they open a picker rather than going somewhere.
   The Portal's global button rule is written for the wide accent-coloured
   submit at the foot of a form, so the four declarations that would make of a
   tile are turned off here; .tile is a class and already wins over an element
   selector for background, border, padding and radius. */
button.tile {
  width: auto;
  margin: 0;
  font: inherit;
  text-align: left;
}
/* Smaller than a library tile, because in the bar it is chrome rather than the
   content. min-height drops to nothing and the width is capped: a long song
   title must not push the arrows off the end of the bar, so the tile ellipses
   instead — the full name is one press away in the picker, and the arrows are
   not. */
.perform-tile {
  flex: 0 1 auto;
  min-width: 0;
  min-height: 0;
  max-width: 22rem;
  padding: 0.35rem 0.7rem;
  gap: 0;
  overflow: hidden;
}
/* Ellipsis rather than the library tile's wrap. In a grid a two-line title
   costs a row that is a line taller; in a bar it costs the chart a line of
   height on every song, for a title that is already readable in eleven cases
   out of twelve. */
.perform-tile .tile-name,
.perform-tile .tile-sub,
.perform-tile-pos {
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Tighter leading than the library's. Three lines in a bar cost the chart
   their full height on every song, and the Portal's 1.5 body leading is for
   paragraphs rather than for a two-word title over a two-word artist. */
.perform-tile .tile-name { font-size: 0.95rem; line-height: 1.2; }
.perform-tile .tile-sub { font-size: 0.78rem; line-height: 1.3; }
/* Where you are in the night. Tabular figures, so the number does not shuffle
   sideways as it goes from 9 to 10 in the corner of somebody's eye. */
.perform-tile-pos {
  color: var(--muted); font-size: 0.75rem; line-height: 1.3;
  font-variant-numeric: tabular-nums;
}
/* The stripe lives in the tile's left padding, which .perform-tile has just
   halved. Restated at the vibed widths so the stripe still has room to sit
   beside the text rather than under it. */
.perform-tile.setlist-tile,
.perform-tile.song-tile-vibed { padding-left: 1.2rem; }

/* ── THE ARROWS ───────────────────────────────────────────────────────────────
   Song, page, page, song — outward from the middle, so the pair that moves you
   a long way is outside the pair that moves you a little.

   Pushed to the end of the bar, with the tiles at the start: the two are read
   at completely different moments — the tiles between songs, the arrows during
   one — and interleaving them would put something you read next to a control
   you press blind. */
.perform-nav {
  display: flex; align-items: center; gap: 0.35rem;
  flex: none;
  margin-left: auto;
}
/* Big targets. This is pressed on a tablet, on a stand, between songs, by
   somebody holding an instrument — the one control on the screen that must be
   hittable without looking at it. */
.perform-arrow {
  display: flex; align-items: center; justify-content: center;
  flex: none;
  width: 2.75rem; height: 2.75rem;
  margin: 0;                     /* The Portal's global button rule gives every
                                    <button> a top margin, for the forms it was
                                    written for. Two of these are real buttons
                                    and would otherwise sit low in the bar. */
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--bg);
  color: var(--accent); font-size: 1.6rem; font-weight: 400; line-height: 1;
  text-decoration: none;
}
.perform-arrow:hover {
  border-color: var(--accent); background: var(--tint-accent-faint);
}
.perform-arrow:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* A control that is visibly there and does nothing, rather than a bar whose
   buttons move as you walk the night — or as you step from a song with pages
   onto one without. pointer-events off so an anchor cannot be followed either:
   the class rides on <a> as well as <button>, and an anchor has no disabled
   attribute to set. */
.perform-arrow.is-disabled,
.perform-arrow[disabled] {
  color: var(--muted); pointer-events: none; opacity: 0.45;
}
/* "2 / 5", between the two page arrows it belongs to. Empty until perform.js
   has counted the pages, and empty again on a song with none — so it holds a
   width whether or not it holds a number, and the arrows either side of it do
   not jump when it fills. */
.perform-pagecount {
  color: var(--muted); font-size: 0.85rem;
  font-variant-numeric: tabular-nums;
  min-width: 3rem; text-align: center;
}
/* The X sits IN the bar rather than floating over a card. .close-x is
   absolutely positioned, which is right on a screen where it hangs over a
   heading and wrong here: this bar is a flex row and the X is its last item, at
   the far end, where "leave" belongs. */
.perform-bar .close-x {
  position: static; flex: none; align-self: center; margin-left: 0.35rem;
}

/* ── A SHORT SCREEN IS A DIFFERENT PROBLEM FROM A NARROW ONE ──
   A phone held sideways on a mic stand is 844x390, and there the bar is not
   competing for width — it is competing for the only thing the chart needs.
   Measured before this rule: 112px of bar in 390px of screen, 29% of the glass,
   and a page that fitted in portrait no longer fitting.

   So under 500px of height the bar gives back everything it can: the top
   padding that exists for a notch, the tiles' second and third lines, and the
   gaps. Height, not width, because a phone in landscape is wide — the
   40rem rule below would never fire on it, which is exactly why this one is
   keyed the other way. */
@media (max-height: 500px) {
  .perform-bar {
    padding-top: max(0.35rem, env(safe-area-inset-top, 0px));
    padding-bottom: 0.35rem;
    gap: 0.4rem;
    flex-wrap: nowrap;
  }
  /* The artist and "Song 1 of 7" go; the name stays. On a screen this short the
     tile's job is to say what you are on and to be pressable, and the two lines
     under it are the ones a glance does not need. */
  .perform-tile .tile-sub,
  .perform-tile .perform-tile-pos { display: none; }
  .perform-tile { padding: 0.25rem 0.55rem; max-width: 14rem; }
  .perform-arrow { min-height: 38px; }
  .perform-bar .close-x { margin-left: 0.2rem; }
}

/* On a phone the two tiles and six controls cannot share one row. The tiles
   take the first, the controls the second — rather than the tiles shrinking to
   the point where a song title is three characters and an ellipsis, which is
   the failure the max-width above is already guarding against on a tablet. */
@media (max-width: 40rem) {
  .perform-bar { flex-wrap: wrap; }
  /* 30%, not the 40% this was. Flex decides where to WRAP from the basis, before
     any shrinking is applied, so two tiles asking for 40% each plus the zoom
     button and the X came to more than one line could hypothetically hold and
     the X was pushed onto a row of its own — the exact three-row bar the note
     below is about, at 196px this time rather than 179px. The tiles still grow
     into whatever is left over, so they end up about 125px wide instead of 148:
     the basis is what they ask for, not what they get. */
  .perform-tile { max-width: none; flex: 1 1 30%; }
  /* `order` rather than moving the X in the markup: it belongs at the END of
     the bar on a wide screen and in the FIRST row on a narrow one, and it is
     one element either way.

     Two rows, not three. Left at its source position the X wrapped onto a third
     row of its own once .perform-nav took the full width, which put the bar at
     179px on a phone — most of the room above the chart, on the screen whose
     whole argument for dropping the Portal's menu was that a row of chrome
     costs the music. Row one is what you read between songs plus the two
     controls that leave it alone — zoom and the X; row two is what you press
     during one.

     The zoom button keeps the default order and lands in row one beside the X
     without needing one of its own. Adding it is what forced the basis above
     down; measure the bar on a phone after touching either. */
  .perform-bar .close-x { order: 1; margin-left: 0; }
  .perform-nav { order: 2; margin-left: 0; width: 100%; justify-content: space-between; }
}

/* The scrolling region, and the only thing the song arrows replace.

   ── overscroll-behavior IS WHY THE BAR STOPPED MOVING ──

   `contain` stops a scroll that has reached the end of this region from
   chaining to the document behind it. On iOS that chained scroll is what makes
   Safari animate its own toolbars and rubber-band the page, so a chart flicked
   past its last line dragged the whole screen — and the bar with it — up behind
   the address bar for the length of the bounce. Which is exactly the
   "sometimes" in a bar that is otherwise nailed down: it was not the layout, it
   was the overscroll.

   The bottom padding takes the home-indicator inset for the same reason the bar
   takes the status-bar one, and keeps the 3rem it already had for the last line
   of a chart. */
.perform-scroll {
  flex: 1; overflow-y: auto;
  /* The horizontal axis exists for the ZOOM, and only ever has anything to do
     when one is applied. An unzoomed chart cannot overflow sideways: perform.js
     fits every block that could, and the two that can still beat it — a tab
     stave and a lyric line — scroll inside their own box (see .tabview and
     .perform-view .chordpro) rather than widening the page. So this is dead
     weight at 100% and is what makes panning work above it.

     Panning IS this scroll. There is no drag handler and no translate anywhere
     in static/js/perform_view.js — dragging a finger across the chart moves it
     because the region scrolls, which brings momentum, overscroll containment
     and a real scrollbar for a mouse along with it, none of which a
     reimplementation would have had. */
  overflow-x: auto;
  overscroll-behavior: contain;
  /* Momentum scrolling on older iOS WebKit; a no-op everywhere it is not
     needed, and this is a screen read by dragging a finger down a chart. */
  -webkit-overflow-scrolling: touch;
  padding: 1.25rem clamp(0.75rem, 2vw, 2rem);
  padding-bottom: calc(3rem + env(safe-area-inset-bottom, 0px));
  padding-left: max(clamp(0.75rem, 2vw, 2rem), env(safe-area-inset-left, 0px));
  padding-right: max(clamp(0.75rem, 2vw, 2rem), env(safe-area-inset-right, 0px));
}
.perform-scroll:focus { outline: none; }   /* Focused programmatically after a
                                              step, for screen readers — a ring
                                              around the whole page is noise. */
.perform-scroll.is-loading { opacity: 0.5; }

.perform-head { margin-bottom: 1.25rem; }
/* Nothing to say: no name, no artist, no key, no tempo, no note, one guide.
   The header still exists because it holds the hidden <h1> a screen reader
   lands on after a step, and it must cost the chart nothing to do that — so it
   gives its margin back and the first section starts at the top of the region. */
.perform-head-bare { margin-bottom: 0; }

/* .perform-facts-row and .perform-facts are gone with the line they laid out.
   Key and tempo are a BLOCK now — .factsblock above — placed by the guide's
   author wherever they want it, or not placed at all. The screen no longer has
   an opinion about where the key belongs. */
/* The note on this placement — "straight into the next one, no gap". The single
   most performance-relevant thing on the screen, so it gets the accent edge
   rather than the muted italic a note gets in the editor. */
.perform-note {
  margin: 0.6rem 0 0; padding: 0.4rem 0.7rem;
  border-left: 3px solid var(--accent);
  background: var(--tint-accent-faint);
  font-style: italic;
}
/* No margin-top: it is the first thing in the header now, not a block under a
   line of facts. */
.perform-guide-pick { display: flex; align-items: center; gap: 0.5rem; }
.perform-guide-pick label { margin: 0; font-size: 0.8rem; color: var(--muted); }
.perform-guide-pick select { width: auto; margin: 0; padding: 0.2rem 0.4rem; font-size: 0.85rem; }
/* .perform-guide-name is gone with the markup. A guide's name above the guide,
   on every song of every set, answered a question nobody had — the picker
   appears when the choice is real and says nothing when it is not. */
.perform-empty { margin-top: 2rem; }
/* The one plain <a> on this screen, coloured to match everything else.
   portal.css has no rule for a bare anchor at all — so it renders in the
   browser's default blue, which on the dark theme is a bright link nobody chose
   sitting in the middle of a performance surface.

   Scoped here rather than fixed as `main a`, deliberately. A global rule would
   repaint every unstyled anchor in the Portal, and there are others (see
   setlists/new.html); that is a decision about the whole application's link
   colour and it belongs to a change that can look at all of them. */
.perform-empty a { color: var(--accent); }
.perform-empty a:hover { color: var(--accent-hover); }

/* ── THE TWO PICKERS ──────────────────────────────────────────────────────────
   Behind each tile, a dialog holding the same tiles again: what you can play
   from, and where you can jump to.

   Wider than the Portal's default dialog and capped in height, because both
   hold a grid of tiles rather than a column of fields — and the list scrolls
   INSIDE the dialog rather than the dialog growing past the window, or Cancel
   ends up somewhere no scroll can reach. */
.perform-dialog {
  width: min(52rem, calc(100vw - 2rem));
  max-height: 85vh;
}
/* The flex column is on [open], NOT on the dialog. A bare `display: flex` here
   beats the user-agent's `dialog:not([open]) { display: none }` — it is a class
   against an element selector — so both pickers rendered permanently, stacked
   under the chart, and the page scrolled past the bottom of the window for no
   visible reason. Caught by measuring documentElement.scrollHeight against the
   viewport while driving the screen in a browser. */
.perform-dialog[open] { display: flex; flex-direction: column; }
/* flex: 1 1 AUTO, not flex: 1. The shorthand `flex: 1` expands to a flex-basis
   of 0%, and this dialog has no definite height - only a max-height. A
   percentage basis against an indefinite main size is the one corner where the
   engines disagree: Blink falls back to the content size, WebKit resolves it to
   zero. So on Safari the list contributed no height, got no free space to grow
   into, ended up 0px tall, and `overflow-y: auto` clipped every tile out of
   sight - an empty picker over a response that had 8KB of songs in it.

   `auto` sizes it from its content in both, the dialog's max-height caps it,
   min-height: 0 lets it shrink to that cap, and then it scrolls. Chromium
   rendered this correctly either way, which is exactly why the difference
   survived being driven in a browser. */
.perform-picker { flex: 1 1 auto; overflow-y: auto; min-height: 0; }

/* ── Touch gestures (2026-09-21, static/js/perform_gestures.js) ──
   Swipe for a page, circle for a song. The region is a gesture surface only
   while its page does not overflow; zoomed in, the class comes off and a finger
   pans as it always has. touch-action: none is what stops the browser taking
   the finger for a scroll halfway round a circle. A tab stave that scrolls in
   its own box keeps its own touch-action, because the browser only looks as far
   up as the nearest scroll container. user-select: none, because a mouse drag
   across a chart would otherwise select it. */
.perform-scroll.is-gesture-surface { touch-action: none; user-select: none; -webkit-user-select: none; }

/* The trail follows the finger and fades when it lifts. Accent-coloured and
   thin: it says "I am watching", not "look at this". pointer-events: none, or
   it would be what the next touch landed on. */
.gesture-trail {
  position: fixed; inset: 0; width: 100vw; height: 100vh;
  pointer-events: none; z-index: 60;
  opacity: 0; transition: opacity 0.35s ease;
}
.gesture-trail.is-drawing { opacity: 0.55; transition: none; }
.gesture-trail polyline {
  fill: none; stroke: var(--accent); stroke-width: 4;
  stroke-linecap: round; stroke-linejoin: round;
}
/* What a gesture was heard as, for a moment, in the middle of the screen: a
   song change can take a second on a venue's wifi, and a gesture with no answer
   gets drawn again. */
.gesture-toast {
  position: fixed; left: 50%; top: 42%; z-index: 61;
  transform: translate(-50%, -50%) scale(0.96);
  padding: 0.6rem 1.1rem; border-radius: 999px;
  background: var(--surface); color: var(--text);
  border: 1px solid var(--accent);
  box-shadow: 0 6px 20px var(--shadow-menu);
  font-weight: 600; pointer-events: none;
  opacity: 0; transition: opacity 0.25s ease, transform 0.25s ease;
}
.gesture-toast.is-shown { opacity: 1; transform: translate(-50%, -50%) scale(1); }
@media (prefers-reduced-motion: reduce) {
  .gesture-trail, .gesture-toast { transition: none; }
}
/* One heading per set, so "the third one in the second set" is findable as it
   is spoken. Absent in All Songs, which has no sets to head. */
.perform-picker-head {
  margin: 1.25rem 0 0.5rem; font-size: 0.85rem; color: var(--muted);
  text-transform: uppercase; letter-spacing: 0.04em;
}
.perform-picker-groups > .perform-picker-head:first-child { margin-top: 0; }
/* The position inside the set, in front of the title. Tabular and given a floor
   width, so twelve titles start in the same column instead of stepping right by
   a digit at song ten. */
.perform-picker-pos {
  display: inline-block; min-width: 1.4rem;
  color: var(--muted); font-weight: 400;
  font-variant-numeric: tabular-nums;
}
/* Where you already are. An accent border rather than a filled background: the
   tiles carry a vibe colour down one edge, and a fill behind them would be a
   second colour on the same object saying something else. */
.perform-picker .is-current {
  border-color: var(--accent);
  box-shadow: inset 0 0 0 1px var(--accent);
}


/* ==========================================================================
   THE PERFORMANCE RENDERING

   Everything under .perform-view applies to the performance screen and to
   neither editing one. The class is on the scrolling region in
   perform/perform.html, which survives every step through a setlist — so one
   set of rules covers a guide read on its own and the same guide read inside a
   running order, because they are now literally the same screen.

   Scoped rather than global because the editor genuinely wants the opposite
   answers: there, every block is a thing you grab, move and remove, so every
   block needs an edge to grab. Here nothing is grabbable and the edges are
   twelve boxes between you and the music.
   ========================================================================== */

/* ── A SECTION IS A BOX. A BLOCK INSIDE IT IS NOT ──
   The chart's structure is the SONG's structure — intro, verse, chorus — and
   that is the division worth drawing on a stand, because it is the one you are
   counting in your head. Which of the four things inside the chorus was
   entered as a separate block is an authoring fact, and framing each of them
   separately turns one chorus into four objects to read past. */
.perform-view .guide-section {
  border: 1px solid var(--border);
  border-radius: var(--radius);
  /* The side padding shrinks with the screen. On a phone every millimetre of it
     comes straight out of the width a tab stave has to fit in — and the fitter
     pays for what is lost by scaling the stave down, so a fixed 1rem here would
     make the chart smaller for the sake of a margin. */
  padding: 0.85rem clamp(0.5rem, 2vw, 1rem) 1rem;
  margin: 1rem 0 0;
}
.perform-view .guide-block {
  border: none;
  background: transparent;
  padding: 0;
}
/* The gap is now the only thing separating one block from the next, so it does
   the work the borders were doing and is wider than the editor's for it. */
.perform-view .guide-blocks { gap: 1rem; }

/* ── SVG IS DRAWN AT A SIZE. LET IT BE DRAWN AT THIS ONE ──
   A scale diagram has an intrinsic width in the markup, and on a phone that
   width is wider than the phone. Nothing about font size reaches it, so it is
   the one thing here that is fixed in CSS rather than measured in JavaScript.
   height: auto so it scales rather than squashing. */
.perform-view .sd-figure svg { max-width: 100%; height: auto; }

/* ── WHAT static/js/perform.js MEASURES, AND WHY THESE ARE IN em ──

   The fitter sets a font size on .tabview, .chordpro and .cp-chordlist and
   expects everything inside to follow. Everything inside did NOT follow: chord
   names, chord chips and stave notes were all sized in rem, which is relative
   to the ROOT and therefore ignores the size its own block was just given. A
   scaled lyric line kept full-size chords over shrinking words, which is worse
   than either extreme on its own.

   The multipliers reproduce the rem sizes exactly at scale 1 — 0.85/0.95 and
   0.8/0.95 against .chordpro's own 0.95rem — so nothing moves until the fitter
   has a reason to move it. */
.perform-view .chordpro-inline .cp-chord { font-size: 0.895em; }
.perform-view .chordpro-chords_over_lyrics .cp-chord,
.perform-view .chordpro-positioned .cp-chord { font-size: 0.842em; }
.perform-view .cp-chip { font-size: 1em; }
.perform-view .tabview-note { font-size: 0.945em; }

/* ── A LYRIC LINE IS SCALED, NOT WRAPPED ──

   Off this screen a long line wraps, which is right for reading and wrong for
   playing: the wrap lands mid-phrase, the chords after it sit over the wrong
   syllables, and the line you are singing is suddenly two lines. So on a stand
   the line is held on one line and the block is scaled down until it fits.

   overflow-x on the wrapper for two reasons. It makes .chordpro a real
   scrolling box, so scrollWidth reports the overflow the fitter is measuring;
   and it is the honest fallback when scaling has gone as far as it usefully
   can — see .is-unfittable below. */
.perform-view .chordpro { overflow-x: auto; }
.perform-view .cp-line { white-space: nowrap; }
.perform-view .cp-text { white-space: pre; }

/* Past the floor. The block is as small as it is worth making and still does
   not fit, so it gets its ordinary behaviour back: a lyric wraps rather than
   being cut off at the edge, and a tab stave goes on scrolling sideways with
   its string labels pinned, which is what .tabview has always done and is the
   right answer for a stave nothing can shrink enough. */
.perform-view .chordpro.is-unfittable .cp-line { white-space: normal; }
.perform-view .chordpro.is-unfittable .cp-text { white-space: pre-wrap; }

/* ── PAGES ──
   Built by static/js/perform.js from the page breaks the author placed, and
   absent entirely from a guide with none: no wrapper, no pages, no cost. The
   bar's two page buttons go dead on such a song rather than disappearing — see
   the note by .perform-arrow.is-disabled. */
.perform-page[hidden] { display: none; }

/* ── ZOOM ──
   The page is what scales, and the scroll region above is what it scales
   INSIDE. Driven by static/js/perform_view.js, which sets the transform
   inline and reads what to set from this device's localStorage.

   transform-origin at the top left corner, and it is load-bearing rather than
   cosmetic: a scroll container can reach overflow to the right and below and
   CANNOT reach overflow above or to the left of its origin. A page growing from
   its centre — the default — would push the first bar of the chart off the top
   left with no scroll position able to bring it back. Growing from 0 0 puts
   every added pixel somewhere reachable. */
.perform-page { transform-origin: 0 0; }

/* ── THE ZOOM PANEL ──
   Three sliders over the chart they adjust, opened NON-MODALLY from the button
   beside the X. perform/perform.html carries the argument for why it is a
   <dialog> and why it is not one of the two .perform-dialog pickers.

   display on [open], NOT on the element — the same trap .perform-dialog
   documents above. A bare `display` here is a class against the user agent's
   `dialog:not([open]) { display: none }` and beats it, so the panel would
   render permanently, over the chart, from the moment the screen loaded. */
.perform-view-panel {
  position: fixed;
  /* Under the bar, whose height is its own top padding plus one arrow plus its
     bottom padding. Written as the same max() the bar uses rather than a
     measured number, so a notch moving the bar down moves this with it. */
  top: calc(max(2.5rem, env(safe-area-inset-top, 0px)) + 3.75rem);
  right: max(0.75rem, env(safe-area-inset-right, 0px));
  left: auto; bottom: auto;
  z-index: 5;
  width: min(21rem, calc(100vw - 1.5rem));
  max-width: none;
  margin: 0;
  padding: 0.9rem 1rem;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--surface);
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
}
.perform-view-panel[open] { display: block; }

/* A label, its slider, and the readout — in a grid rather than the stacked
   label-over-input the avatar editor uses. That one is in a 12rem-wide column
   beside a picture and has the height to spare; this one is over a chart and
   every row of it is a row of the chart nobody can see. */
.perform-view-row {
  display: grid;
  grid-template-columns: 5.25rem 1fr 2.75rem;
  align-items: center;
  gap: 0.5rem;
  margin-bottom: 0.55rem;
}
.perform-view-row label { margin: 0; font-size: 0.85rem; }
.perform-view-row input[type="range"] {
  width: 100%; margin: 0; accent-color: var(--accent);
}
/* A disabled pan slider is dimmed rather than hidden. The row keeps its height
   and its label, so the panel does not change shape as the zoom crosses 100%
   and the control is visibly present-but-inapplicable rather than missing. */
.perform-view-row input[type="range"]:disabled { opacity: 0.35; }
/* With a dial (rotary.js) the middle column is the dial's own width and the
   value sits beside it rather than across the panel. */
.perform-view-row.rotary-host { grid-template-columns: 5.25rem auto 1fr; }
.perform-view-row.rotary-host output { text-align: left; }
.perform-view-row output {
  color: var(--muted); font-size: 0.8rem; text-align: right;
  font-variant-numeric: tabular-nums;
}
.perform-view-note { margin: 0.5rem 0 0; }
.perform-view-actions {
  display: flex; flex-wrap: wrap; gap: 0.5rem;
  margin-top: 0.75rem;
}
.perform-view-actions button {
  width: auto; margin: 0; padding: 0.4rem 0.7rem; font-size: 0.85rem;
}

/* The icon is an SVG rather than a glyph — see perform/perform.html. It sits in
   a .perform-arrow, which sizes its children with a font-size meant for « and
   ×, so the drawing is given a real size of its own. */
.perform-view-open svg { width: 1.35rem; height: 1.35rem; }

/* On a phone the bar wraps to two rows and its height stops being something a
   calc can predict, so the panel stops trying to hang under it and becomes a
   sheet across the bottom instead. It is out of the way of a bar it can no
   longer measure, and it is under the thumb rather than at arm's reach. */
@media (max-width: 40rem) {
  .perform-view-panel {
    top: auto;
    bottom: 0; left: 0; right: 0;
    width: auto;
    padding-bottom: calc(0.9rem + env(safe-area-inset-bottom, 0px));
    border-radius: var(--radius) var(--radius) 0 0;
    border-bottom: 0;
  }
}

/* The page CONTROLS are not here. They are two buttons in the bar at the top
   of the screen (.perform-arrow above), which never scrolls, so the page you
   are on and the way to the next one are reachable from the bottom of a
   six-page chart. There used to be a second sticky bar inside this region
   holding them, and it was one row of controls too many: the page position
   and the song position are the same kind of answer to the same question and
   belong in the same place. */

/* A section split across a page boundary keeps its heading on the far side,
   marked as the continuation it is. Without the mark the second half reads as a
   second Verse 2. */
.guide-section-cont {
  margin-left: auto;
  color: var(--muted); font-size: 0.72rem;
  text-transform: none; letter-spacing: 0; font-style: italic;
}


/* ==========================================================================
   Song blocks — the library, the reuse markers, and the notification queue

   Every colour here is a token, so both themes are covered by the one set of
   rules: :root[data-theme="light"] redefines the same names, and the five that
   carry meaning were already re-picked there for contrast against the light
   ground. A literal hex in this block would be correct in exactly one theme.
   ========================================================================== */

/* -- the arrangement ------------------------------------------------------ */

/* The whole width of the page. It used to share it with a library panel that
   blocks were dragged from; that list is the "Existing blocks" tab of the Add
   dialog now, and where a block goes is the gap its Add was pressed in. */
.guide-layout { display: block; }
.guide-arrangement { min-width: 0; }

/* The preamble — blocks about the whole song rather than a part of it. Set
   apart from the numbered sections without being loud about it: it is a real
   part of the chart, not a footnote. */
.guide-preamble .guide-section-head { border-bottom-style: dashed; }

/* -- the library panel ---------------------------------------------------- */

.panel-count { margin-left: auto; color: var(--muted); font-size: 0.82rem; }
.panel-controls {
  display: flex; align-items: center; gap: 0.75rem;
  flex: none; margin: 0.6rem 0 0.5rem;
}
.input-inline {
  flex: 1 1 auto; min-width: 0;
  padding: 0.3rem 0.5rem;
  border: 1px solid var(--border); border-radius: 6px;
  background: var(--bg); color: var(--text); font-size: 0.85rem;
}
.input-inline::placeholder { color: var(--placeholder); }

.library-list {
  list-style: none; margin: 0; padding: 0;
  overflow-y: auto; min-height: 0;
  display: flex; flex-direction: column; gap: 0.35rem;
}
.library-item {
  display: flex; align-items: center; gap: 0.5rem;
  padding: 0.45rem 0.55rem;
  border: 1px solid var(--border); border-radius: 8px;
  background: var(--surface);
  cursor: grab;
}
/* Hover and focus are the shared rule further up. */
.library-item:active { cursor: grabbing; }
.library-item-main { display: flex; flex-direction: column; min-width: 0; gap: 0.1rem; }
.library-item-title {
  color: var(--text); font-size: 0.9rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.library-item-meta { color: var(--muted); font-size: 0.75rem; }
.library-item-flags { margin-left: auto; display: flex; gap: 0.3rem; flex: none; }

/* -- what a block says about itself -------------------------------------- */

/* Whose riff this is. Quiet: it appears on every block somebody else wrote, so
   it has to read as a label rather than a claim. */
.guide-block-owner {
  color: var(--muted); font-size: 0.78rem;
  padding: 0.05rem 0.4rem;
  border: 1px solid var(--border); border-radius: 999px;
}

/* Which instrument a block is for. The same pill as the owner chip beside it,
   deliberately: they answer the two halves of "whose is this and what is it
   for", and giving them different weights would make one of them look like an
   alert. Neither is.

   The accent tint is the only difference, and it is what stops "Bass Guitar"
   from reading as a second person's name. */
.guide-block-instrument {
  color: var(--accent); font-size: 0.78rem;
  padding: 0.05rem 0.4rem;
  border: 1px solid var(--border); border-radius: 999px;
}

/* A block written for an instrument the band has since removed. Muted and
   struck through rather than hidden: the label still tells the reader what the
   part is for, and the mark is what makes it fixable rather than invisible —
   the same choice the song editor makes for a removed vibe. Not danger-tinted,
   because nothing is broken; the block draws exactly as it always did. */
.guide-block-instrument.is-removed {
  color: var(--muted);
  text-decoration: line-through;
  text-decoration-color: var(--border);
}

/* The update offer. Warning-tinted rather than danger: nothing is wrong, and
   nothing has happened to this chart — somebody else moved on and this is the
   invitation to follow. Danger colouring here would read as "your guide is
   broken", which is the opposite of the guarantee the pin makes. */
.guide-block-update {
  display: inline-flex; align-items: center; gap: 0.4rem;
  margin-left: 0.4rem;
}
.badge-update {
  font-size: 0.72rem; padding: 0.1rem 0.45rem; border-radius: 999px;
  background: var(--tint-warning); color: var(--warning);
  border: 1px solid var(--tint-warning-strong);
}

/* -- small quiet badge, used in both places above ------------------------- */

.badge-quiet {
  font-size: 0.75rem; padding: 0.05rem 0.4rem; border-radius: 999px;
  background: var(--overlay-mid); color: var(--muted);
  border: 1px solid var(--border);
}

/* -- the count beside a menu item ---------------------------------------- */

/* Accent rather than danger, and drawn only when non-zero. A red dot on the
   chrome of every page trains people to ignore it; this is a count of things
   somebody chose to be told about, not an alarm. */
.navbadge {
  display: inline-block; min-width: 1.15rem; text-align: center;
  margin-left: 0.35rem; padding: 0 0.3rem;
  font-size: 0.72rem; line-height: 1.15rem; border-radius: 999px;
  background: var(--accent); color: var(--accent-ink); font-weight: 600;
}

/* -- the notification queue ---------------------------------------------- */

.notification-list { list-style: none; margin: 1rem 0 0; padding: 0; }
.notification {
  display: flex; align-items: flex-start; gap: 1rem;
  padding: 0.75rem 0.9rem;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface);
  margin-bottom: 0.5rem;
}
/* Dealt with, and still here. Faded rather than removed: "you were told about
   this" is worth keeping, and a list that empties itself gives somebody no way
   to check what they dismissed. */
.notification.is-resolved { background: var(--overlay-faint); opacity: 0.7; }
.notification-main { flex: 1 1 auto; min-width: 0; }
.notification-text { margin: 0; color: var(--text); }
.notification-meta { margin: 0.25rem 0 0; }
.notification-side {
  flex: none; display: flex; flex-direction: column;
  align-items: flex-end; gap: 0.35rem;
}
.page-intro { margin: 0.25rem 0 1rem; max-width: 46rem; }


/* -- empty sections as drop targets --------------------------------------- */

/* An editable guide keeps every section on screen, because a section is where
   you drop something and a hidden target is one nobody can reach. An empty one
   therefore has to look like an invitation rather than an oversight: a dashed
   outline that is clearly not content, sized so it is a comfortable target
   rather than a line. */
.guide-blocks { min-height: 0; }
.guide-blocks.is-empty {
  min-height: 3.25rem;
  border: 1px dashed var(--border); border-radius: 8px;
  display: flex; align-items: center; justify-content: center;
}
.guide-drop-hint {
  margin: 0; color: var(--muted); font-size: 0.82rem;
  pointer-events: none;   /* never eat the drop */
}
/* Shown only while the section is empty. A hint sitting under real content
   would be one more line to read past on every chart. */
.guide-blocks:not(.is-empty) .guide-drop-hint { display: none; }

/* The section being dragged over. Accent rather than a colour change on the
   blocks themselves, so the answer to "where will this land" is the container
   and not a guess from which block moved. */
.guide-blocks.drop-target {
  border: 1px dashed var(--accent);
  background: var(--tint-accent-faint);
}


/* -- the library item, now that it carries a face and a footprint --------- */

/* Three columns: who wrote it, what it is, and what you can do about it. The
   middle one is the only one allowed to grow, so a long title truncates instead
   of pushing the badges off the panel. */
.library-item { align-items: center; }
.library-item-avatar { flex: none; display: inline-flex; }
.library-item-main { flex: 1 1 auto; }

/* Where its author files it. Quiet and one line: it is a hint about the
   arrangement, not a field — and on a block used in five sections it has to
   truncate rather than wrap the row to three lines. The full list is in the
   title attribute. */
.library-item-where {
  display: block; margin-top: 0.1rem;
  color: var(--muted); font-size: 0.75rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.library-item-flags { align-items: center; }
/* The preview control only resolves on hover or keyboard focus. Every row
   carries one, and five of them lit up down a narrow panel competes with the
   titles for attention — which are the thing somebody is actually scanning. */
.library-item .js-preview-block { opacity: 0; font-size: 0.75rem; }
.library-item:hover .js-preview-block,
.library-item:focus-within .js-preview-block,
.library-item .js-preview-block:focus-visible { opacity: 1; }

/* -- the author's face on a block in the guide ---------------------------- */

.guide-block-avatar { flex: none; display: inline-flex; margin-right: 0.15rem; }

/* -- the preview dialog --------------------------------------------------- */

/* Wider than the block editor's dialog. A tab stave is as wide as a tab stave,
   and the point of previewing is to see it at the width it will have in the
   guide — a preview that wraps the staves is showing something the guide will
   not. */
.wide-dialog {
  width: min(56rem, 92vw);
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface); color: var(--text);
  padding: 1rem 1.25rem;
}
.wide-dialog::backdrop { background: var(--scrim); }
.dialog-head { display: flex; align-items: baseline; gap: 0.6rem; margin-bottom: 0.75rem; }
.dialog-head h2 { margin: 0; font-size: 1.05rem; }
.dialog-actions {
  display: flex; align-items: center; gap: 0.75rem;
  margin-top: 1rem; justify-content: flex-end;
}
.preview-body {
  max-height: 60vh; overflow: auto;
  border: 1px solid var(--border); border-radius: 8px;
  padding: 0.6rem;
  background: var(--bg);
}
/* The preview draws the real partial, so it arrives with the guide's own block
   chrome. Inside the dialog that frame is redundant - the dialog IS the frame. */
.preview-body .guide-block { border: none; background: transparent; padding: 0; }


/* -- adding a block, at the place it goes -------------------------------- */

/* ── AN "ADD" IN EVERY GAP ──
   Before the first block of every section, between every two, and after the
   last: one small control that already knows the section and the position.
   guide.js puts them in (refreshAddSlots) and keeps them in step as blocks
   are added, moved and removed.

   This replaced a library panel beside the arrangement and an "Add a block"
   dialog that asked which part of the song. Both questions are answered by
   where somebody pressed, so the arrangement gets the whole width.

   Quiet — a small dashed pill, with a rule either side that appears under the
   pointer — because a long guide has dozens of them and they must not read as
   content. The pill itself is always drawn: a tablet has no hover, and a
   control that only appears under a pointer is one a finger never finds.

   The grid gap closes up in an editable guide because the slots now carry the
   spacing between blocks; .perform-view sets its own and never has slots. */
.guide-layout[data-can-edit] .guide-blocks { gap: 0.1rem; }
.guide-add {
  display: flex; align-items: center; gap: 0.5rem;
  min-height: 1.9rem;
}
.guide-add::before,
.guide-add::after {
  content: ""; flex: 1 1 0;
  border-top: 1px dashed transparent;
}
.guide-add:hover::before, .guide-add:hover::after,
.guide-add:focus-within::before, .guide-add:focus-within::after {
  border-top-color: var(--border);
}
.guide-add-btn {
  flex: none; width: auto; margin: 0;
  padding: 0.1rem 0.75rem;
  background: transparent; color: var(--muted);
  border: 1px dashed var(--border); border-radius: 999px;
  font-size: 0.78rem; font-weight: 500; line-height: 1.5;
  cursor: pointer;
}
.guide-add-btn:hover,
.guide-add-btn:focus-visible {
  color: var(--accent); border-color: var(--accent); border-style: solid;
  background: var(--tint-accent-faint);
}
/* An empty section is nothing but its one Add, so the rules either side are
   the dashed box around it instead. */
.guide-blocks.is-empty .guide-add { flex: 1 1 auto; justify-content: center; }
.guide-blocks.is-empty .guide-add::before,
.guide-blocks.is-empty .guide-add::after { display: none; }

/* ── THE ADD DIALOG ──
   Three tabs for the three different things "add" can mean: write something
   new, reuse something already written, or drop in one of the markers. The
   flex column is on [open] only — see .perform-dialog for what a bare
   display: flex on a <dialog> does to one that is closed. */
.add-dialog { width: min(54rem, 94vw); }
.add-dialog[open] { display: flex; flex-direction: column; max-height: 85vh; }
.add-dialog .dialog-head { flex: none; }
.add-where { color: var(--accent); }
.addtabs {
  display: flex; flex: none;
  margin-bottom: 0.75rem; border-bottom: 1px solid var(--border);
}
.addtab {
  width: auto; margin: 0; padding: 0.45rem 1rem;
  background: none; color: var(--muted);
  border: 0; border-bottom: 2px solid transparent; border-radius: 0;
  font-size: 0.9rem; cursor: pointer;
}
.addtab:hover { background: var(--overlay); color: var(--text); }
.addtab.active { color: var(--accent); border-bottom-color: var(--accent); background: none; }
/* flex: 1 1 AUTO, and min-height 0, for the reason .perform-picker gives: a
   0% basis against a dialog with only a max-height is 0px tall on WebKit. */
.addpane { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
.addtype-list {
  list-style: none; margin: 0; padding: 0;
  display: grid; gap: 0.5rem;
  grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr));
}
.addtype {
  width: 100%; height: 100%; margin: 0;
  display: flex; flex-direction: column; align-items: flex-start; gap: 0.2rem;
  padding: 0.65rem 0.8rem; text-align: left;
  background: var(--surface); color: var(--text);
  border: 1px solid var(--border); border-radius: 8px;
  cursor: pointer;
}
.addtype:hover,
.addtype:focus-visible { border-color: var(--accent); background: var(--tint-accent-faint); }
.addtype-name { font-weight: 600; font-size: 0.92rem; }
.addtype-desc { color: var(--muted); font-size: 0.8rem; line-height: 1.35; font-weight: 400; }
.addtype-marker { border-style: dashed; }
.addtype-marker .addtype-name { color: var(--accent); }

/* The existing-blocks list, in the dialog rather than a side panel: nothing is
   dragged from it any more, and there is room for Preview to be always
   visible rather than appearing under a pointer a tablet does not have. */
.library-picker .panel-controls { flex-wrap: wrap; margin-top: 0; }
.library-picker .library-list { overflow: visible; }
.library-picker .library-item { cursor: pointer; }
.library-picker .library-item:active { cursor: pointer; }
.library-picker .js-preview-block { opacity: 1; }


/* ── The page turner test (diagnostics/keys.html) ──────────────────────────

   Instrumentation, styled to be read at arm's length by somebody holding an
   iPad with a pedal at their feet — so the lamp and the counter carry the
   answer and the prose is support. */

.kp-steps { margin: 0 0 1.25rem; padding-left: 1.2rem; color: var(--muted); }
.kp-steps li { margin-bottom: 0.3rem; }

.kp-channels { display: flex; flex-direction: column; gap: 0.6rem; margin-bottom: 1.5rem; }

.kp-channel {
  display: grid;
  grid-template-columns: auto 1fr auto;
  grid-template-areas: "lamp name arm" "lamp note arm" "read read read";
  align-items: center;
  column-gap: 0.75rem;
  padding: 0.7rem 0.85rem 0.8rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 6px;
}
.kp-channel.armed { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }

/* Dark until this row hears something, then it flashes. The single element
   somebody is actually watching while their hands are busy. */
.kp-lamp {
  grid-area: lamp;
  width: 14px; height: 14px; border-radius: 50%;
  background: var(--border); border: 1px solid var(--border);
  align-self: start; margin-top: 0.3rem;
}
.kp-channel.armed .kp-lamp { border-color: var(--accent); }
.kp-channel.kp-hit .kp-lamp {
  background: var(--accent); border-color: var(--accent);
  box-shadow: 0 0 8px var(--accent);
}

.kp-name { grid-area: name; font-weight: 600; }
.kp-note { grid-area: note; line-height: 1.35; }
.kp-arm { grid-area: arm; min-height: 44px; }

.kp-readout {
  grid-area: read;
  margin-top: 0.6rem; padding-top: 0.55rem;
  border-top: 1px dashed var(--border);
  display: flex; flex-wrap: wrap; gap: 0.3rem 1.2rem;
  font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace;
  font-size: 0.82rem;
}
.kp-readout b { color: var(--accent); }
.kp-readout .kp-none { color: var(--muted); }

.kp-log { max-height: 16rem; overflow: auto; }
.kp-log .datatable td { font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace; font-size: 0.8rem; }
.kp-key { color: var(--accent); font-weight: 700; }

.kp-env {
  font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace;
  font-size: 0.78rem; color: var(--muted);
  white-space: pre-wrap; word-break: break-word;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: 6px; padding: 0.7rem 0.8rem; margin: 0;
}

/* Focusable, never visible, and never a software keyboard. Not display:none —
   an element that is not rendered cannot be focused, and focus is the whole
   point of these three. */
/* ── IN THE VIEWPORT, NOT OFF SCREEN, AND THAT IS NOT A DETAIL ──
   
   These were at left: -9999px, which is the usual way to hide something while
   keeping it focusable. On iOS it is not: WebKit refuses focus() to an element
   outside the viewport, silently — focus stays where it was and the call
   returns as though it worked.
   
   That flaw made the first round of testing worthless. Every channel that was
   supposed to be focusing something was in fact focusing nothing, so all of
   them behaved identically to "nothing focused" and the results said the device
   was hopeless when they only said the probe was.
   
   So: one pixel in the top-left corner, opacity 0, still laid out and still in
   view as far as the engine is concerned. Never display:none or
   visibility:hidden — both make an element unfocusable everywhere, not just on
   iOS. */
.kp-sink {
  position: fixed; left: 0; top: 0;
  width: 1px; height: 1px; opacity: 0;
  border: 0; padding: 0; margin: 0; background: transparent;
  font-size: 16px;
}

@media (max-width: 34rem) {
  .kp-channel { grid-template-columns: auto 1fr;
                grid-template-areas: "lamp name" "lamp note" "arm arm" "read read"; }
  .kp-arm { width: 100%; margin-top: 0.6rem; }
}

/* Where the page parks keyboard focus so a page turner's keys reach it.
   Created by PCW.holdKeyFocus() in static/js/inline_crud.js.

   overflow: hidden is the load-bearing declaration, not a tidy-up. WebKit takes
   Page Up, Page Down and the arrows in order to SCROLL THE FOCUSED ELEMENT, so
   a focused element that can scroll is a standing request for the behaviour
   this exists to avoid. This one cannot.

   position: fixed so it never lengthens the document. Never display:none —
   an element that is not rendered cannot be focused, and being focusable is its
   entire job. */
/* Where the page parks keyboard focus so a page turner's keys reach it.
   Built by PCW.holdKeyFocus() in static/js/inline_crud.js — and it is an
   <input>, because measuring on the device showed WebKit hands these keys only
   to something that can type. inputmode="none" is what keeps the on-screen
   keyboard down; that attribute is set in the JS, next to the reasoning.

   In the viewport, not off screen: iOS refuses focus() to anything outside it
   and says nothing about having refused, so a sink at left:-9999px is a sink
   that never holds focus at all.

   One pixel at zero opacity, and never display:none or visibility:hidden —
   both make an element unfocusable everywhere, which is the one thing this
   must never be. */
.pcw-keysink {
  position: fixed; left: 0; top: 0;
  width: 1px; height: 1px;
  overflow: hidden;
  opacity: 0;
  border: 0; padding: 0; margin: 0;
  background: transparent;
  /* 16px stops iOS zooming the viewport when a field takes focus. It cannot be
     seen at 1px and zero opacity; the rule is about what Safari does, not about
     what it looks like. */
  font-size: 16px;
  /* Not a tap target. It sits in the top-left corner, over whatever is there. */
  pointer-events: none;
}
.pcw-keysink:focus { outline: none; }

/* The probe's scrollable sink: overflow AND content, because the case under
   test is a focused element that actually has somewhere to scroll. */
.kp-sink-scroll { overflow-y: auto; height: 20px; }

/* The browser refused to focus what this channel asked it to focus, so whatever
   that row reports is meaningless. Loud on purpose: a quiet version of this
   readout is what let a whole round of testing pass with every channel focusing
   nothing. */
.kp-refused { color: var(--danger); font-weight: 700; }

/* "Tap to arm pedal" in the performance bar.

   Shown only while the page does NOT hold keyboard focus, which on iOS is until
   the first tap after a page load and on a desktop browser is never. Warning
   tinted rather than danger: nothing is broken, the screen simply cannot hear a
   pedal yet, and the fix is to touch it.

   It is now a LAST RESORT rather than a step, and it is held back for three
   seconds before it appears at all — static/js/perform.js has the account. The
   sizing below was doing that job on its own and could not: a control that
   flickers in and out beside the page arrows is unreadable however little it
   moves the chart.

   Sized like the arrows around it so the bar does not change height when it
   appears and disappears — a control that shoves a chart down half a line on a
   stand is worse than the thing it is warning about. */
.perform-arm {
  font-size: 0.78rem;
  line-height: 1.1;
  padding: 0.35rem 0.6rem;
  margin: 0 0.2rem;
  white-space: nowrap;
  /* --warning is the palette's own token and carries a theme-aware value in
     both. Used for the text and the outline rather than as a fill: a solid
     block of colour in this bar would read as a control to press during a song,
     which is the one thing this bar is designed against. */
  color: var(--warning);
  background: transparent;
  border: 1px solid var(--warning);
  border-radius: 999px;
  cursor: pointer;
}
.perform-arm:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }


/* The panel's own row for it. .perform-view-row is a label-then-control grid
   for the sliders; a checkbox wants the tick first and the words after, and it
   wants the whole row to be the target. */
.perform-view-check {
  display: flex; align-items: center; gap: 0.6rem;
  cursor: pointer; min-height: 44px; margin-bottom: 0.55rem;
}
.perform-view-check input { flex: none; margin: 0; }
.perform-view-check span { font-size: 0.9rem; }
.perform-view-check:hover { color: var(--accent-hover); }

/* A stray "}" sat on the line above until 2026-09-13. A browser does not report
   one: it reads it as the start of the NEXT rule's selector, finds that selector
   invalid, and drops the whole rule — which was the first one below, the one
   that sets the fixed-width face. The switch saved, the class was on the block,
   and the font never changed. portal/tests/stylesheet.test.py now fails on any
   brace that closes nothing. */

/* ── A block the author asked to be drawn fixed-width ──────────────────────

   For tab that arrived in a note or under a lyric line rather than in a
   tablature block, and for anything else whose meaning is in the columns
   lining up. V107 has the account.

   On the ARTICLE, so it reaches whatever the type renders — sanitised HTML in
   a note, chords over words in a lyric block — without each of them needing to
   know about it. The heading and the chips in the header are deliberately left
   alone: the switch is about the CONTENT, and a monospaced heading over
   proportional siblings would read as a mistake.

   white-space: pre-wrap on the note body, because tab pasted into rich text
   arrives as text with real newlines in it, and the whole point of asking for
   fixed-width is that those lines are meant to stay where they were put. */
.guide-block.is-monospace .guide-notes,
.guide-block.is-monospace .chordpro {
  font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace;
  /* Ligatures off: a coding face turning -- into an em dash would silently
     rewrite a tab stave into something that is not one. */
  font-variant-ligatures: none;
}

/* Tab pasted into a note arrives as text with real newlines in it, and the
   whole point of asking for fixed-width is that those lines stay where they
   were put. Only inside a monospaced block — rich text elsewhere should still
   reflow. */
.guide-block.is-monospace .guide-notes {
  white-space: pre-wrap;
}

/* A fixed-width face runs wider than a proportional one at the same size, so a
   lyric line that fitted before can now overflow. perform.js measures and
   scales .chordpro already; this only has to stop it widening the page in the
   editor, where nothing measures. */
.guide-block.is-monospace .chordpro { overflow-x: auto; }

/* ── CHORD DIAGRAMS ─────────────────────────────────────────────────────────
   Drawn by pcw_portal/chords.py, everywhere they appear — the picker, the
   band's Chords tab, and the strip above a words-and-chords block. The SVG
   carries no colour of its own, so it is styled here once and inherits the
   theme wherever it lands. */
.chorddiagram {
  display: block;
  width: 100%; height: auto;
  max-width: 7rem;
  color: var(--text);
}
.chorddiagram .cd-nut { fill: var(--text); }
.chorddiagram .cd-fret,
.chorddiagram .cd-string { stroke: var(--border); stroke-width: 1; }
.chorddiagram .cd-dot { fill: var(--accent); }
.chorddiagram .cd-finger {
  fill: var(--accent-ink); font-size: 8px; font-weight: 700;
  font-family: inherit;
}
.chorddiagram .cd-open { fill: none; stroke: var(--muted); stroke-width: 1.4; }
/* The free chord tools' extras (2026-09-21): the neck's markers, faint and
   under the strings; a capo standing in for the nut; and each string's note
   written in its dot, or in the ring of an open string. */
.chorddiagram .cd-inlay { fill: var(--border); opacity: 0.8; }
.chorddiagram .cd-capo { fill: var(--text); }
.chorddiagram .cd-note {
  fill: var(--accent-ink, #fff); font-size: 6.5px; font-weight: 700;
  font-family: inherit; pointer-events: none;
}
.chorddiagram .cd-note.is-open { fill: var(--muted); }

/* A piano chord (keyboard.py): two octaves or more, the held keys in the
   accent colour with their names on them, and each C named faintly. */
.kbdiagram { display: block; width: 100%; height: auto; }
.kbdiagram .kb-white { fill: #fdfcf9; stroke: var(--border); stroke-width: 1; }
.kbdiagram .kb-black { fill: #2a2622; stroke: #2a2622; }
.kbdiagram .kb-key { cursor: default; }
.chord-tool.is-identifying .kbdiagram .kb-key { cursor: pointer; }
.kbdiagram .kb-key.is-on { fill: var(--accent); stroke: var(--accent); }
.kbdiagram .kb-name {
  fill: var(--accent-ink, #fff); font-size: 8px; font-weight: 700;
  font-family: inherit; pointer-events: none;
}
.kbdiagram .kb-name.is-black { font-size: 6.5px; }
.kbdiagram .kb-c { fill: #9a938a; font-size: 6.5px; font-family: inherit; pointer-events: none; }
.chord-tool-board.is-keys { width: 26rem; max-width: 100%; }
.chord-tool.is-identifying .chord-tool-board.is-keys .kbdiagram {
  outline: 2px dashed var(--accent); outline-offset: 4px; border-radius: 4px;
}
.chorddiagram .cd-mute { fill: var(--muted); font-size: 11px; font-family: inherit; }
.chorddiagram .cd-fretnum {
  fill: var(--muted); font-size: 9px; font-family: inherit;
}

/* ── THE CHORD PICKER ───────────────────────────────────────────────────────
   A dialog over the block editor: type a chord, look at it, take it. */
#chord-dialog { width: clamp(20rem, 60vw, calc(100vw - 2rem)); max-height: 85vh; }
.chordpicker { display: flex; flex-direction: column; gap: 0.7rem; }

.chordpicker-search { display: flex; align-items: center; gap: 0.6rem; }
/* The search box is the thing this dialog is FOR, so it is the size of the
   thing this dialog is for. */
#chord-search { margin: 0; flex: 1 1 auto; font-size: 1.1rem; padding: 0.45rem 0.6rem; }

.chordpicker-filters {
  display: flex; flex-wrap: wrap; align-items: center; gap: 0.3rem 0.6rem;
}
.chordpicker-filter-label {
  color: var(--muted); font-size: 0.7rem;
  text-transform: uppercase; letter-spacing: 0.08em;
}
.chordpicker-chips { display: flex; flex-wrap: wrap; gap: 0.3rem; }
.chordchip {
  width: auto; margin: 0; padding: 0.2rem 0.55rem;
  background: none; color: var(--muted);
  border: 1px solid var(--border); border-radius: 999px;
  font-size: 0.8rem; min-height: 1.9rem;
}
.chordchip.is-on {
  color: var(--accent); border-color: var(--accent);
  background: var(--tint-accent-soft);
}

/* The results scroll inside the dialog rather than growing it, so the search
   box and the pager never leave the screen while somebody is scanning. */
.chordpicker-results {
  overflow-y: auto; max-height: 46vh;
  display: flex; flex-direction: column; gap: 0.6rem;
  padding-right: 0.2rem;
}
.chordcard {
  border: 1px solid var(--border); border-radius: var(--radius);
  padding: 0.5rem 0.6rem; background: var(--overlay-faint);
}
.chordcard-head { display: flex; align-items: baseline; gap: 0.5rem; margin-bottom: 0.4rem; }
.chordcard-name { font-size: 1.05rem; color: var(--accent); }
.chordcard-voicings { display: flex; flex-wrap: wrap; gap: 0.5rem; }

/* One voicing: the picture, what it is called, and anything unusual about it.
   A button, because taking it is the only thing you do with it. */
.chordvoicing {
  width: auto; margin: 0; padding: 0.35rem;
  display: flex; flex-direction: column; align-items: center; gap: 0.15rem;
  background: var(--surface); color: var(--text);
  border: 1px solid var(--border); border-radius: var(--radius);
  cursor: pointer;
}
.chordvoicing:hover, .chordvoicing:focus-visible {
  border-color: var(--accent); background: var(--tint-accent-faint);
}
.chordvoicing-label { font-size: 0.75rem; color: var(--muted); }
/* "yours", "partial", "bass E" — the things that change whether this is the
   voicing somebody wants, and nothing else. */
.chordvoicing-meta { font-size: 0.65rem; color: var(--accent); }

.chordpicker-pager { display: flex; align-items: center; gap: 0.75rem; }

/* ── BUILDING ONE ──
   A grid of buttons, one per string per fret. Drawn by the browser rather than
   fetched, because it is a control and has to answer a click at once —
   static/js/chordpicker.js has the argument. */
.chordpicker-build > summary { color: var(--muted); font-size: 0.85rem; cursor: pointer; }
.chordbuild { padding-top: 0.5rem; }
.chordboard { border-collapse: collapse; margin: 0 auto 0.5rem; }
.chordboard td { padding: 0; }
.chordboard-cell {
  width: 2rem; height: 1.75rem; margin: 0; padding: 0;
  background: none; color: var(--muted);
  border: 1px solid var(--border); border-radius: 0;
  font-size: 0.9rem; line-height: 1;
}
/* The nut row is the open-or-muted row, so it reads as the edge of the
   fretboard rather than as another fret. */
.chordboard tr:first-child .chordboard-cell {
  border-bottom-width: 3px; border-bottom-color: var(--text);
  height: 1.4rem;
}
.chordboard-cell.is-on { background: var(--accent); border-color: var(--accent); }
.chordboard-cell:hover { background: var(--overlay); }
.chordboard-cell.is-on:hover { background: var(--accent-hover); }

.chordbuild-fields { display: flex; flex-wrap: wrap; gap: 0.4rem 0.9rem; }
.chordbuild-field {
  display: flex; align-items: center; gap: 0.35rem;
  margin: 0; flex: 1 1 10rem; min-width: 0;
}
.chordbuild-field-wide { flex: 1 1 100%; }
.chordbuild-field > span { color: var(--muted); font-size: 0.8rem; flex: none; }
.chordbuild-field input { margin: 0; padding: 0.25rem 0.4rem; font-size: 0.85rem; min-width: 0; }

/* ── The band's Chords tab ── */
.voicing-diagram { flex: none; width: 4.5rem; }
.voicing-diagram .chorddiagram { max-width: 4.5rem; }
.voicing-tag { font-family: ui-monospace, "Consolas", monospace; font-size: 0.85rem; }

/* ══════════════════════════════════════════════════════════════════════════
   EXPORT PDFS

   The two export forms. Nothing here reaches the PDF itself — that is
   static/css/pdf.css, over the guide's own markup. These are the screens where
   somebody says which charts and how they should look.
   ══════════════════════════════════════════════════════════════════════════ */

/* A checkbox or radio with its words beside it, inside a stacked form.

   Not .check-inline, which looks nearly identical and is not the same control:
   that one is the "show removed" filter that sits at the END of a flex row of
   fields, and carries align-self, a padding-bottom and a nowrap to hold it
   level with the inputs beside it. All three are wrong in a column, where the
   label is a line of its own and is allowed to wrap. */
.check-row {
  display: flex; align-items: baseline; gap: 0.5rem;
  margin: 0.15rem 0; font-size: 0.9rem; cursor: pointer;
}
.check-row input[type="checkbox"],
.check-row input[type="radio"] {
  margin: 0; flex: none;
  /* Nudged onto the text's baseline. align-items: baseline puts the BOX's
     baseline on the label's, which for a replaced element is its bottom edge,
     so without this the tick sits a couple of pixels low against the words. */
  transform: translateY(0.1em);
}

/* The caption over a group of controls that is not one field, so it cannot be a
   <label> — a radio group labels itself through aria-labelledby pointing here.
   Matches the weight and spacing of a real label so a form does not read as
   though one line of it is a different kind of thing. */
.field-label {
  margin: 0.9rem 0 0.2rem; font-size: 0.85rem; font-weight: 600;
  color: var(--text);
}

.radio-row { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; }

.export-options {
  margin: 1.4rem 0 0; padding: 0.9rem 1rem 1rem;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--surface);
}
.export-options legend {
  padding: 0 0.4rem; font-size: 0.85rem; font-weight: 600; color: var(--muted);
}

/* ── The running order on the set list form ── */

.export-songs { margin-top: 1.2rem; }
.export-songs-head .field-label { margin-top: 0; }

.export-song-list {
  list-style: none; margin: 0.6rem 0 0; padding: 0;
  border: 1px solid var(--border); border-radius: var(--radius);
  /* It can be a forty-song night. Capped and scrolled so the options below it
     stay reachable without a long scroll past a list nobody needs to read in
     full — most people change nothing here. */
  max-height: 26rem; overflow-y: auto;
}

.export-song {
  display: flex; gap: 0.7rem; align-items: baseline;
  padding: 0.45rem 0.75rem;
  border-top: 1px solid var(--border);
}
.export-song:first-child { border-top: none; }

/* The set heading. Sticky so that on a scrolled list it is still possible to
   tell which set the song under the cursor is in — the same job the label on a
   tab stave does while the frets scroll past it. */
.export-song-set {
  position: sticky; top: 0; z-index: 1;
  padding: 0.4rem 0.75rem;
  background: var(--surface); color: var(--muted);
  font-size: 0.8rem; font-weight: 600; text-transform: uppercase;
  letter-spacing: 0.06em;
  border-top: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
}
.export-song-set:first-child { border-top: none; }

.export-song-num {
  flex: none; width: 2rem; text-align: right;
  color: var(--muted); font-size: 0.85rem;
  /* So the numbers form a column rather than shifting as they pass 9 and 99. */
  font-variant-numeric: tabular-nums;
}

.export-song-body { display: flex; flex-direction: column; gap: 0.1rem; min-width: 0; }
.export-song-name { font-size: 0.92rem; }
.export-song-guide { font-size: 0.82rem; }
.export-song-guides { display: flex; flex-direction: column; gap: 0.05rem; margin-top: 0.15rem; }
.export-song-guides .check-row { font-size: 0.82rem; }

/* A submit that is busy. It stays enabled — static/js/exports.js says why —
   so this is the only signal that the press landed. */
.form-actions button[aria-busy="true"] {
  opacity: 0.7; cursor: progress;
}

/* ── MIDI channel labels ───────────────────────────────────────────────────
   A fixed grid of sixteen rows, not a list you add to. .block-list gives it
   the card list's reset; everything below is the row.

   The channel NUMBER is the anchor and is deliberately the most solid thing on
   the row — it is the one part somebody cross-references against a device's
   front panel, and the one part this screen cannot change. */
.midi-channel-list { display: flex; flex-direction: column; gap: 0.35rem; }

.midi-channel-row {
  display: flex; align-items: center; gap: 0.85rem;
  padding: 0.4rem 0; border-bottom: 1px solid var(--border);
}
.midi-channel-row:last-child { border-bottom: 0; }

/* Fixed width and tabular figures so the sixteen numbers form a column rather
   than a ragged edge — "Channel 1" and "Channel 16" are different widths, and
   a list of sixteen is exactly where that shows. */
.midi-channel-num {
  flex: none; width: 6.5rem;
  font-size: 0.85rem; color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.midi-channel-label { flex: 1; min-width: 0; max-width: 26rem; }

/* Per row, because one status line above sixteen rows cannot say which row it
   is talking about. Fixed width so a message appearing does not shove the
   input it refers to sideways. */
.midi-channel-state {
  flex: none; width: 10rem;
  font-size: 0.82rem; color: var(--muted);
}
.midi-channel-state.is-ok   { color: var(--success); }
.midi-channel-state.is-bad  { color: var(--danger); }

/* On a phone the number sits above its box rather than stealing a third of the
   width from it. The status keeps the row it belongs to by wrapping with it. */
@media (max-width: 34rem) {
  .midi-channel-row { flex-wrap: wrap; gap: 0.3rem 0.75rem; }
  .midi-channel-num { width: auto; }
  .midi-channel-label { flex: 1 1 100%; max-width: none; }
  .midi-channel-state { width: auto; }
}

/* ── MIDI template editor ──────────────────────────────────────────────────
   The blanks and the steps inside the template dialog. Rows rather than cards:
   these are short, ordered, and read as a recipe — a list of lines somebody
   scans top to bottom, not a set of things to pick between. */
.midi-sub-head { font-size: 0.95rem; margin: 1.25rem 0 0.35rem; }

/* ── The template dialog is wide ──
   It was the Portal's default 30rem, which is right for a dialog holding a name
   and a tick list and wrong for this one: a recipe, its blanks and its steps all
   at once, three lists of controls stacked in a column narrower than a phone in
   landscape. The block editor is wide for the same reason and the shape is
   copied from it — floored at 30rem so it can never come out NARROWER than the
   default on a small screen, capped in rem so a 4K monitor gets a readable
   dialog rather than a metre of form, and held inside the viewport with the
   1rem margin every other dialog leaves.

   max-height with overflow is not optional now that the two panes can be long:
   a dialog taller than the window puts Save somewhere the reader cannot reach.
   Save sits ABOVE the panes here, so the failure would be quieter still — the
   dialog would simply look as though it had no way out. */
#midi-template-dialog {
  width: min(clamp(30rem, 70vw, 68rem), calc(100vw - 2rem));
  max-height: 88vh;
  overflow-y: auto;
}

/* The name and the description share a line when there is room. auto-fit rather
   than two fixed columns, so the same rule stacks them on a phone without a
   breakpoint to keep in step with the dialog's own width. */
.midi-template-head {
  display: grid; gap: 0 1.25rem;
  grid-template-columns: repeat(auto-fit, minmax(min(15rem, 100%), 1fr));
}

/* Blanks beside steps. minmax(20rem, 1fr) is what decides the switch: two
   columns while the dialog's content box holds 40rem plus the gap, one below
   that — which is the narrow dialog, and the phone, and a desktop window
   dragged small, all handled by the one measurement.

   The min(20rem, 100%) is load-bearing, not defensive. A bare minmax(20rem, 1fr)
   floors the track at 20rem even when it is the ONLY column, so at 375px the
   single pane stayed 320px wide inside a 287px dialog and scrolled sideways —
   measured. min() lets the last remaining column fall back to the container. */
.midi-template-panes {
  display: grid; gap: 0 2.25rem;
  grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
}
/* min-width: 0 because a grid item's default `auto` minimum is its content, and
   the widest thing in a pane is a <select> of message names — without this the
   column refuses to narrow and the two panes overflow the dialog instead of
   stacking. */
.midi-template-pane { min-width: 0; }
/* The first heading in each pane loses the leading margin .midi-sub-head has
   for the stacked case — side by side, the two headings must line up. */
.midi-template-pane > .midi-sub-head:first-child { margin-top: 0.25rem; }

.midi-editor-list { list-style: none; margin: 0 0 1rem; padding: 0; }

.midi-editor-row {
  display: flex; align-items: center; gap: 0.6rem;
  padding: 0.45rem 0; border-bottom: 1px solid var(--border);
  font-size: 0.9rem;
}
.midi-editor-row:last-child { border-bottom: 0; }

/* The step or blank itself takes the room; everything after it is controls. */
.midi-editor-text { flex: 1; min-width: 0; }

.midi-editor-meta { color: var(--muted); font-size: 0.82rem; white-space: nowrap; }

/* ── The row's own controls ──
   These inherited `width: 100%` from the base button rule and rendered as 147px
   accent slabs where they should be quiet link-style controls — two of them per
   row, in a dialog that is otherwise all text fields. The base rule no longer
   does that, and these are stated explicitly anyway because this row is the one
   place it was worst.

   The reorder arrows sit next to each other and must not resize as they enable
   and disable — a row whose buttons move when you use them is one you misclick. */
.midi-editor-row button {
  width: auto;
  margin: 0;
  padding: 0.2rem 0.55rem;
  font-size: 0.82rem;
  background: none;
  color: var(--accent);
  font-weight: 500;
}
.midi-editor-row button:hover { background: var(--overlay-mid); color: var(--accent-hover); }
.midi-editor-row button.danger { color: var(--danger); }
.midi-editor-row button:disabled { color: var(--muted); background: none; cursor: default; }
/* The meta text was `white-space: nowrap` beside a `flex: 1` name, and when the
   slabs took the room it printed on top of the name rather than beside it.
   flex: none stops it being compressed into the same column. */
.midi-editor-meta { flex: none; }

.midi-inline-form {
  background: var(--overlay); border-radius: 8px;
  padding: 0.85rem 1rem; margin-bottom: 0.5rem;
}
.midi-inline-fields {
  display: flex; gap: 0.75rem; flex-wrap: wrap; align-items: flex-end;
}
/* The name grows, the two numbers stay small — a 0-127 box as wide as a name
   box reads as wanting more than three digits. */
.midi-inline-fields .field { margin-bottom: 0.5rem; }
.midi-inline-fields .field:first-child { flex: 1; min-width: 10rem; }
.midi-inline-fields input[type="number"] { width: 7rem; }

/* Nought steps is the number that matters on a template card. */
.warn-value { color: var(--danger); }

@media (max-width: 34rem) {
  .midi-editor-row { flex-wrap: wrap; }
  .midi-editor-text { flex: 1 1 100%; }
  .midi-inline-fields .field:first-child { flex: 1 1 100%; }
}

/* A fill value the band has since put out of range. The number is deliberately
   left alone by the procedure, so the editor is the only place it gets said. */
.midi-action-value.is-bad,
input.midi-action-value.is-bad { border-color: var(--danger); }
.note.is-bad { color: var(--danger); }

/* ── What a song sends ─────────────────────────────────────────────────────
   Rows, not cards: this is a running order — who sends what, where it goes —
   and the useful reading is straight down the column. There is nothing to
   open, because the action itself is edited on its owner's own screen. */
.midi-attach-list { list-style: none; margin: 0 0 1rem; padding: 0; }

.midi-attach-row {
  display: flex; align-items: center; gap: 0.75rem;
  padding: 0.55rem 0; border-bottom: 1px solid var(--border);
  font-size: 0.9rem; flex-wrap: wrap;
}
.midi-attach-row:last-child { border-bottom: 0; }

/* The owner first, because this list's job is saying whose rig each line
   drives. Fixed width so the names form a column rather than a ragged edge. */
.midi-attach-owner {
  display: flex; align-items: center; gap: 0.45rem;
  flex: none; width: 9rem; min-width: 0;
}
.midi-attach-owner-name {
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.midi-attach-name { flex: 1; min-width: 8rem; }
.midi-attach-name .muted { display: block; font-size: 0.82rem; }

.midi-attach-meta { color: var(--muted); font-size: 0.82rem; white-space: nowrap; }

/* Not ready means the Conductor will not send it at all — it is left out of the
   bundle. That is worth more than muted text. */
.midi-attach-warn { color: var(--danger); font-size: 0.82rem; white-space: nowrap; }
.midi-attach-row.is-not-ready { background: color-mix(in srgb, var(--danger) 6%, transparent); }

@media (max-width: 40rem) {
  .midi-attach-owner { width: auto; }
  .midi-attach-name { flex: 1 1 100%; }
}

/* ── MIDI blocks on a chart ────────────────────────────────────────────────
   The two blocks that DO something. Deliberately quiet: a chart is for reading
   and these are furniture beside it, not the thing being read.

   Disabled is the DEFAULT state, because off a Conductor there is nothing to
   send to — the Portal's own performance view, a PDF, a file on a memory stick.
   The offline script enables the button when it finds a Conductor answering. A
   control that looks live and does nothing is worse than one that admits it. */
.midi-block {
  display: flex; align-items: center; gap: 0.6rem;
  margin: 0.4rem 0; font-size: 0.92rem;
}

.midi-send-button {
  min-height: 46px; padding: 0.55rem 1.1rem;
  border-radius: 9px; font: inherit; font-weight: 600;
  background: var(--accent); color: var(--accent-ink); border: 0;
  cursor: pointer;
}
/* A big target: this is pressed mid-song, one-handed, in the dark. */
.midi-send-button:disabled { opacity: 0.45; cursor: default; }

.midi-block-note, .midi-block-text { color: var(--muted); font-size: 0.85rem; }

/* The auto-send marker is a line of text, not a control — there is nothing to
   press and it must not look pressable. */
.midi-block-auto { color: var(--muted); }
.midi-block-mark { color: var(--accent); font-size: 0.8rem; }

/* Sent just now. Fades on its own: the only feedback a stand can give is that
   something happened, and it must not stay on the chart afterwards. */
.midi-send-button.is-sent { background: var(--success); color: #fff; }
.midi-block.is-sent .midi-block-mark { color: var(--success); }

/* ---------------------------------------------------------------------------
   Pedal settings on a chart — pedalboard pass 18.

   The cards are .pedal-sheet-card, drawn by pedalboard_sheet.js and styled
   with the rest of the settings sheet below; this only lays them out, a row of
   them wrapping, in the order the block lists them (a board's own order for a
   whole board). The card sets its own ground and ink from the theme, so it
   matches the chart it sits in; see the settings sheet below. */
.pedal-block-cards { display: flex; flex-wrap: wrap; gap: 0.6rem; }

/* The editor's pane: three sizes across the top, then a row per pedal with its
   name beside the preset picker. The name is the label, so the pair stays on
   one line and a long pedal name wraps rather than pushing the picker away. */
.pedal-mode { display: flex; flex-wrap: wrap; gap: 0.25rem 1.25rem; border: 0; padding: 0; margin: 0 0 0.6rem; }
.pedal-mode legend { padding: 0; margin-bottom: 0.25rem; font-weight: 600; }
.pedal-choices { display: grid; gap: 0.4rem; margin: 0.5rem 0; }
.pedal-choice { display: grid; grid-template-columns: minmax(8rem, 14rem) minmax(0, 1fr); gap: 0.25rem 0.75rem; align-items: center; }
.pedal-choice label { margin: 0; }
/* The amps offered beside a board (amps pass C). */
.pedal-choices-heading { margin: 0.4rem 0 0; font-weight: 600; }
@media (max-width: 34rem) { .pedal-choice { grid-template-columns: minmax(0, 1fr); } }
.pedal-block-board { margin: 0 0 0.4rem; font-weight: 600; }

/* The same settings as tables: the PDF, and a browser with no JavaScript.
   One table per pedal in board order, one table ROW per physical row of
   controls on the pedal. The band down the left is the pedal's own colour,
   set inline, as the card's is. */
.pedal-paper { display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; align-items: flex-start; }
.pedal-paper-table {
  border-collapse: collapse;
  border-left: 0.35rem solid #5b6472;
  margin: 0;
  font-size: 0.9rem;
}
.pedal-paper-table caption { text-align: left; padding: 0 0 0.2rem 0.4rem; }
.pedal-paper-note { display: block; font-size: 0.8rem; color: var(--muted); }
.pedal-paper-table td { padding: 0.15rem 0.6rem 0.15rem 0.4rem; vertical-align: baseline; }
.pedal-paper-name { margin-right: 0.35rem; }
.pedal-paper-value { font-weight: 700; white-space: nowrap; }
/* Nothing said about this control: a rule to write on, as on the drawn sheet. */
.pedal-paper-value.is-blank { display: inline-block; min-width: 3rem; border-bottom: 1px solid currentColor; }

/* ---------------------------------------------------------------------------
   Incoming MIDI rules — a rule drawn as the sentence somebody wrote.

   The layout is doing real work here rather than decorating. A rule has seven
   parts and reads as one thought; as a column of labelled fields it becomes
   seven things to correlate before you know what it does. So the form and the
   row are both a single wrapping line of words and controls, and the meaning of
   each blank comes from the words either side of it.

   Wrapping, not scrolling: on a phone the sentence runs to four or five lines
   and stays readable, where a horizontally scrolling row would hide the half
   that says what happens.
   --------------------------------------------------------------------------- */

.sentence,
.midi-rule-sentence {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem 0.5rem;
  line-height: 2.1;
}

.midi-rule-sentence { line-height: 1.7; }

/* The connecting words. Quieter than the choices, so the eye lands on what was
   filled in rather than on "when" and "then". */
.sentence-word,
.rule-lead {
  color: var(--muted);
  font-size: 0.94rem;
}

.rule-part {
  color: var(--muted);
  font-size: 0.9rem;
}

/* The two blanks that only sometimes exist. Inline so they vanish without
   leaving a gap in the sentence. */
.sentence-part {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem 0.5rem;
}

.sentence-part.hidden { display: none; }

/* Sized to the three digits a MIDI data byte can hold, so a number field does
   not sit in the sentence looking like a text box. */
.midi-rule-form input.num {
  width: 5.5rem;
  text-align: center;
}

.midi-rule-form select { width: auto; min-width: 8rem; }

/* ── A RULE IS A CARD LIKE EVERY OTHER RECORD ──
   This list used to be one full-width row per rule, on the argument that a
   sentence needs the width and rules are compared down the page. What that
   produced was eight identical grey lines: device, message, channel and action
   all the same size and weight, so telling one rule from another meant reading
   every word of every one.

   The card now leads with the action and the box, and carries the trigger
   underneath as detail — so the list can be scanned by what each rule DOES.
   Three across on a wide screen at .block-list's own 19rem tiling, which is
   also what the rest of the Portal's records do. */

.midi-rule-trigger {
  margin: 0;
  display: flex; flex-wrap: wrap; align-items: baseline;
  gap: 0.2rem 0.45rem;
  font-size: 0.9rem;
  line-height: 1.6;
}

/* The sentence's connecting words are quiet in the FORM, where they sit between
   controls. On a card they are quieter still: the trigger is the small print
   under the action, so nothing in it competes with the card's own name. */
.midi-rule-trigger .rule-lead,
.midi-rule-trigger .rule-part { font-size: 0.85rem; }
.midi-rule-trigger strong { font-weight: 600; }

/* Who the rule moves. The one thing that genuinely divides this list in two —
   a rule that moves the show for everybody and a rule that turns one person's
   page are different kinds of thing, and before this nothing said so until you
   reached the end of the sentence. */
.badge-band {
  color: var(--accent); border-color: var(--accent);
  background: var(--tint-accent-soft);
  text-transform: none; letter-spacing: 0;
}
.badge-member {
  color: var(--muted); border-color: var(--border);
  background: var(--overlay);
  text-transform: none; letter-spacing: 0;
}

/* Present for screen readers, absent for everyone else. Several selects in the
   sentence carry a visible word rather than a label, so the label still has to
   exist somewhere. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* A section on a page that has no tab strip.
   NOT .tab-panel: that one is display:none until PCW.tabs marks it active, which
   is right on the band page and invisible everywhere else. Reusing it here cost
   one render where the whole screen below the intro simply was not there. */
.page-panel {
  margin: 1.25rem 0 0;
  padding-top: 1.25rem;
  border-top: 1px solid var(--border);
}

/* ---------- the stage: four views of one room ----------

   Top, front, side and perspective, two by two, and none of them is spare: the
   plan shows COVERAGE because coverage happens on the floor, the front shows
   WHICH ONE because it looks like what somebody is standing in front of, the
   side shows HOW FAR DOWNSTAGE because depth is into the screen in one of the
   other two and flat on the floor in the other, and the perspective shows all
   three at once.

   The renderer reads its ink off these custom properties rather than hard-coding
   colours, because the same file draws on the Conductor's dark admin screen. */
.stage-views {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 1rem;
  margin: 0.75rem 0 0.5rem;
}

.stage-view { margin: 0; }

/* The caption is ABOVE its canvas, because it carries the maximize button and a
   control that scrolls under the picture it acts on is one nobody finds. */
.stage-view-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.5rem;
  color: var(--muted);
  font-size: 0.78rem;
  margin-bottom: 0.3rem;
}
.stage-view-head b { color: var(--text); font-weight: 600; }
.stage-view-tools { display: inline-flex; align-items: baseline; gap: 0.5rem; }

/* The house-lights toggle. The label stays put -- aria-pressed says which way
   it is to a screen reader -- and the dot says it to everybody else: warm and
   filled when the room is lit, an empty ring when it is dark. */
.stage-house { white-space: nowrap; }
.stage-house-dot {
  display: inline-block;
  width: 0.6em;
  height: 0.6em;
  margin-right: 0.3em;
  border-radius: 50%;
  border: 1px solid currentColor;
  vertical-align: baseline;
}
.stage-house[aria-pressed="true"] .stage-house-dot {
  background: #f2c14e;
  border-color: #f2c14e;
}

.stage-max {
  background: none;
  border: 1px solid transparent;
  border-radius: var(--radius);
  color: var(--muted);
  cursor: pointer;
  font-size: 0.85rem;
  line-height: 1;
  padding: 0.15rem 0.3rem;
}
.stage-max:hover { color: var(--text); border-color: var(--border); }
.stage-max[aria-pressed="true"] { color: var(--text); border-color: var(--border); }

/* ── A BARE FOUR-UP: NO SENTENCES, TOOLS OVER THE PICTURE ── (2026-09-17)
   The scene screen, which passes `stage_views_captions = false` -- see
   _stage_views.html for Jeff's words. The caption row is worth about 37px per
   pane and the sentence in it teaches which projection this is, which is a
   thing to read once on the layout editor and not every time a look is set.
   The tools stay, laid over the top-right of the picture on dark glass, exactly
   as that screen's own light picker is laid over the top-left. */
.stage-views.is-bare .stage-view { position: relative; }
.stage-views.is-bare .stage-view-head {
  position: absolute; z-index: 15;
  top: 0.5rem; right: 0.5rem;
  margin: 0;
  padding: 0.15rem 0.4rem;
  /* The page's own surface, because the three flat views draw on --bg and a
     dark chip on a pale plan reads as a hole in it. The room overrides this
     below: it is black whatever the theme, so its chip has to be too. */
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
}
.stage-views.is-bare .stage-view[data-view="persp"] .stage-view-head {
  background: rgba(12, 13, 15, 0.72);
  color: #e8eaed;
  border-color: rgba(255, 255, 255, 0.2);
}
.stage-views.is-bare .stage-view[data-view="persp"] .stage-view-head .linkish,
.stage-views.is-bare .stage-view[data-view="persp"] .stage-view-head .stage-max { color: #e8eaed; }
.stage-views.is-bare .stage-view[data-view="persp"] .stage-view-head .linkish:hover,
.stage-views.is-bare .stage-view[data-view="persp"] .stage-view-head .stage-max:hover { color: #fff; }

/* ── THE ROOM IS ALWAYS ON A BLACK GROUND ── (2026-09-17)
   Jeff: "Can we make the background of the perspective view black always. I
   think it will look nicer." It took the page's own --bg, so the space around
   the room was pale on the light theme and a beam reaching past a wall washed
   out against it. The renderer is created with `alpha: true`, so what is behind
   the room IS this; nothing about the room's own palette moves, since
   stage_three.js reads the theme off --stage-ink and never off the background. */
.stage-view[data-view="persp"] .stage-canvas { background: #000; }

.stage-canvas {
  --stage-ink: var(--text);
  --stage-rule: var(--border);
  --stage-deck: var(--overlay-faint);
  /* The selection ring and the aim spoke. One colour, so that a fixture picked
     in one pane is recognisably the same fixture ringed in the other three. */
  --stage-pick: var(--accent, #3b82f6);
  display: block;
  width: 100%;
  /* A stage is wider than it is deep, and the canvas sizes itself to this box
     in device pixels -- so the height is set here rather than by the drawing. */
  height: 13rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  /* Every pane is a drag surface. Without this, dragging a fixture on a touch
     screen scrolls the page instead and the gesture never reaches the canvas. */
  touch-action: none;
}
/* The arrow keys nudge whatever pane has focus, so which pane that is has to be
   visible. Without the outline the same key press moves a light along different
   axes with nothing on screen saying why. */
.stage-canvas:focus-visible { outline: 2px solid var(--stage-pick, #3b82f6); outline-offset: 2px; }

/* ── MAXIMIZED ──
   One pane fills the panel and the others are not rendered. In the page rather
   than through the fullscreen API, because a fullscreen canvas takes the detail
   panel and the tray with it -- and moving a light while unable to read its
   height is the thing this was meant to fix. */
/* ── TWO VIEWS ── (2026-09-21) One flat view on the left, chosen by
   data-left, and the room always on the right. `:not(.is-max)` because a
   maximized pane wins: the rules below would otherwise keep the left pane
   showing beside it. */
.stage-layout-bar { display: flex; flex-wrap: wrap; gap: 0.4rem 1.2rem; margin: 0.35rem 0 0; }
.stage-layout-group { display: inline-flex; gap: 0.3rem; }
.stage-layout-group[hidden] { display: none; }
.stage-numbered { font-size: 0.85rem; margin: 0; }
.stage-layout-bar.is-bare { justify-content: flex-end; }
/* The numbered tags' key, under the plan. Columns, because a crowded stage is
   exactly when it is long. */
.stage-key {
  margin: 0.3rem 0 0; padding-left: 1.6rem;
  font-size: 0.8rem; columns: 3 9rem; column-gap: 1.2rem;
}
.stage-key[hidden] { display: none; }
.stage-key li { break-inside: avoid; }
.stage-layout-bar button {
  font: inherit; font-size: 0.85rem; padding: 0.15rem 0.6rem; cursor: pointer;
  background: none; color: var(--text-muted); border: 1px solid var(--border);
  border-radius: 999px;
}
.stage-layout-bar button[aria-pressed="true"] { color: var(--text); border-color: var(--text); }
.stage-views.is-two:not(.is-max) .stage-view { display: none; }
.stage-views.is-two:not(.is-max)[data-left="plan"]  .stage-view[data-view="plan"],
.stage-views.is-two:not(.is-max)[data-left="front"] .stage-view[data-view="front"],
.stage-views.is-two:not(.is-max)[data-left="side"]  .stage-view[data-view="side"],
.stage-views.is-two:not(.is-max) .stage-view[data-view="persp"] { display: block; }
.stage-views.is-two .stage-canvas { height: min(34rem, 68vh); }

.stage-views.is-max { grid-template-columns: minmax(0, 1fr); }
.stage-views.is-max .stage-view { display: none; }
.stage-views.is-max[data-max="plan"]  .stage-view[data-view="plan"],
.stage-views.is-max[data-max="front"] .stage-view[data-view="front"],
.stage-views.is-max[data-max="side"]  .stage-view[data-view="side"],
.stage-views.is-max[data-max="persp"] .stage-view[data-view="persp"] { display: block; }
.stage-views.is-max .stage-canvas { height: min(34rem, 68vh); }

/* ── NARROW SCREENS ──
   Four panes on a phone is four pictures too small to use, so one is shown and
   the switcher above picks it. It drives the same data-max the corner buttons
   do; the plan is what a page with no choice made yet shows. */
.stage-switch { display: none; }
@media (max-width: 46rem) { .stage-layout-bar { display: none; } }

@media (max-width: 46rem) {
  .stage-views { grid-template-columns: minmax(0, 1fr); }
  .stage-views .stage-view { display: none; }
  .stage-views[data-max=""] .stage-view[data-view="plan"],
  .stage-views[data-max="plan"]  .stage-view[data-view="plan"],
  .stage-views[data-max="front"] .stage-view[data-view="front"],
  .stage-views[data-max="side"]  .stage-view[data-view="side"],
  .stage-views[data-max="persp"] .stage-view[data-view="persp"] { display: block; }
  .stage-canvas, .stage-views.is-max .stage-canvas { height: min(20rem, 55vh); }

  .stage-switch { display: flex; gap: 0.4rem; flex-wrap: wrap; margin: 0.5rem 0; }
  .stage-switch button {
    background: none;
    border: 1px solid var(--border);
    border-radius: var(--radius);
    color: var(--muted);
    cursor: pointer;
    font: inherit;
    font-size: 0.85rem;
    padding: 0.25rem 0.7rem;
  }
  .stage-switch button[aria-pressed="true"] { color: var(--text); border-color: var(--text); }
  /* The corner button would maximize what is already the only pane. */
  .stage-max { display: none; }
}

/* Where the picked box is, in feet, live while it is dragged. A drag without it
   is a guess -- the numbers below only catch up when the pointer comes up. */
.stage-readout {
  color: var(--muted);
  font-size: 0.85rem;
  margin: 0 0 0.75rem;
  min-height: 1.2em;
}
.stage-help { margin-top: 0.6rem; }

/* ── .row IS NOT STYLED GLOBALLY ──
   scenes.html and midi_rules.html both use a bare .row and both stack because
   of it. Scoped here rather than fixed globally: a global rule would change two
   screens this pass was not asked to touch, and doing that blind is how a
   layout somebody is happy with quietly moves. Worth raising separately. */
#stage-form .row,
#stage-detail .row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  align-items: center;
}
#stage-form .row input { width: auto; }
#stage-detail .row label { color: var(--muted); font-size: 0.85rem; }
#stage-detail .row input { width: 5rem; }

.stage-tray { display: flex; flex-wrap: wrap; gap: 0.6rem; align-items: baseline;
              margin-bottom: 0.75rem; }
/* The buttons live inside a span, so the tray's own gap does not reach them --
   which ran "Back Wash 2/2Front Wash 2/2" together into one word. */
#stage-unplaced { display: inline-flex; flex-wrap: wrap; gap: 0.6rem; }
.stage-tray button { white-space: nowrap; }

.stage-detail { border-top: 1px solid var(--border); padding-top: 0.75rem; }
.stage-detail-name { min-width: 8rem; }
.stage-note { margin: 0.2rem 0 0; font-size: 0.85rem; }

/* ---------- the stage editor page (2026-09-14) ----------
   One layout, and the four-up is most of the screen. The canvases are sized off
   the viewport rather than a fixed 13rem, and the rest is split between the two
   rows of panes -- or given whole to one when a pane is maximized.

   ── THE ALLOWANCES ARE MEASURED, NOT GUESSED ──
   The first guess was 17rem and 14rem, and on a 1400 by 1000 window the views
   ran 89px and 102px past the bottom of it. Measured on the real page: the views
   start 301px down (the top bar, the padding, the header and a toolbar that
   wraps to two lines), a caption is 25px and the row gap 10px. 24rem and 22rem
   end them about 20px above the bottom of the window, which is the room the
   toolbar needs if it wraps once more on a narrower screen. */
.stage-editor-page main .card.stage-editor { padding: 1rem clamp(0.75rem, 1.5vw, 1.5rem); }
.stage-editor-head h1 { margin: 0; }
.stage-back { margin: 0 0 0.2rem; font-size: 0.85rem; }
.stage-sub {
  display: flex; flex-wrap: wrap; gap: 0.25rem 0.6rem; align-items: baseline;
  margin: 0.15rem 0 0.4rem;
}
.stage-toolbar {
  display: flex; flex-wrap: wrap; gap: 0.35rem 1.5rem; align-items: baseline;
  margin: 0.25rem 0;
}
.stage-toolbar .stage-tray { margin-bottom: 0; }
#stand-unplaced { display: inline-flex; flex-wrap: wrap; gap: 0.6rem; }
.stage-editor-page .stage-views { margin-top: 0.4rem; gap: 0.6rem; }
.stage-editor-page .stage-canvas { height: max(14rem, calc((100vh - 24rem) / 2)); }
.stage-editor-page .stage-views.is-max .stage-canvas { height: max(22rem, calc(100vh - 22rem)); }
/* Two views are one row, so each is as tall as a maximized one. */
.stage-editor-page .stage-views.is-two .stage-canvas { height: max(22rem, calc(100vh - 24rem)); }

/* ── THE DIALOGS ──
   Floating over the views, top right, and NON-modal: the picture behind them
   stays live, so a light can be dragged with its dialog open. Inside
   .stage-wrap, which is what dragging one by its header keeps it within.
   Deliberately no `display` of its own, so the hidden attribute keeps winning. */
.stage-wrap { position: relative; }
.stage-dialog {
  position: absolute;
  top: 1.9rem;
  right: 0.5rem;
  z-index: 20;
  width: min(23rem, calc(100% - 1rem));
  max-height: calc(100% - 2.5rem);
  overflow: auto;
  padding: 0.6rem 0.9rem 0.75rem;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  box-shadow: 0 10px 28px rgba(0, 0, 0, 0.35);
}
.stage-dialog-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  margin: -0.1rem 0 0.5rem;
  cursor: move;
  user-select: none;
}
.stage-dialog-close {
  background: none;
  border: 1px solid transparent;
  border-radius: var(--radius);
  color: var(--muted);
  cursor: pointer;
  font-size: 1.2rem;
  line-height: 1;
  padding: 0.1rem 0.4rem;
}
.stage-dialog-close:hover { color: var(--text); border-color: var(--border); }
.stage-dialog .row {
  display: flex; flex-wrap: wrap; gap: 0.5rem 0.8rem; align-items: center;
  margin-bottom: 0.5rem;
}
.stage-dialog .row label { color: var(--muted); font-size: 0.85rem; }
.stage-dialog .row input[type="number"] { width: 5rem; }
/* #stage-detail .row input above is an id rule and sets every input to 5rem,
   which made the box-label field five characters wide. */
#stage-detail .row input[type="text"] { width: 12rem; }
.stage-dialog .layout-name-field,
.stage-dialog .layout-name-field input { width: 100%; }
.stage-dialog .field-note { margin: 0.3rem 0; }
.stage-dialog-actions { display: flex; justify-content: flex-end; gap: 0.75rem; margin-top: 0.5rem; }

@media (max-width: 46rem) {
  .stage-editor-page .stage-canvas,
  .stage-editor-page .stage-views.is-max .stage-canvas { height: min(22rem, 60vh); }
  /* On a phone a floating dialog would cover the one pane there is, so it sits
     under the views in the page's own flow -- and is not dragged. */
  .stage-dialog {
    position: static;
    width: auto;
    max-height: none;
    margin-top: 0.75rem;
    box-shadow: none;
  }
  .stage-dialog-head { cursor: default; }
}

/* ---------- chases ---------- */
#chase-form .row, .chase-step-row, #steps-body + .row, .page-panel > .row {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  align-items: center;
}
#chase-form .row input[type="text"],
#chase-form .row input[type="number"] { width: auto; }

/* ── A CHASE IS A SEQUENCE, SO IT IS ONE COLUMN ──
   .block-list is a responsive grid, which is right for cards somebody scans
   and wrong for an ordered list: two columns turned "1, 2, 3" into a reading
   order nobody could follow, and the ORDER is the entire point of this screen. */
.chase-steps { display: block; margin-bottom: 0.5rem; }
.chase-steps > li + li { margin-top: 0.4rem; }
/* A step is a row rather than a card: the whole point of the screen is the
   ORDER, and cards with their own padding push the sequence apart. */
.chase-steps .block-card { padding: 0.5rem 0.75rem; }
.step-number {
  flex: none;
  width: 1.8rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  font-family: ui-monospace, Consolas, monospace;
}
.chase-step select { width: auto; min-width: 8rem; }
.chase-step input.num { width: 6.5rem; }
.chase-step input:disabled { opacity: 0.45; }
/* A step's look, beside its number — the Lighting Scenes list's picture at a
   size that leaves the row room for its controls (scene_pictures.js). */
.scene-thumb.is-small { flex: none; width: 7.5rem; }
.pill.warn { background: var(--warning-faint, var(--overlay-faint)); color: var(--warning); }
label.check { display: inline-flex; align-items: center; gap: 0.35rem; font-size: 0.9rem; }

/* ── A VISIBLE NAME ON A BARE NUMBER OR DATE BOX ──

   These boxes carried an sr-only label and a `title`, which made them reachable
   by a screen reader and unreadable by everybody else. A `title` is a TOOLTIP,
   and there is no hover on a phone or a tablet -- which is exactly what a band
   has in its hands at a venue -- so the meaning of a box reading "24" was
   available on a laptop and nowhere at a gig.

   A placeholder could not have covered it either: a number box shows none, and
   a date box shows a FORMAT rather than a meaning.

   The wrapper is not decoration. Every row using this is `flex-wrap`, and a
   label that wraps away from its own box ends up sitting beside somebody
   else's -- a name that is wrong is worse than a name that is missing. */
.labelled-field {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
}
.inline-label {
  color: var(--muted);
  font-size: 0.8rem;
  white-space: nowrap;
}

/* ---------- audio: THE EIGHT PAGES' DIALOGS ---------- (2026-09-24)

   Conductor > Audio moved its forms out from under the lists and into dialogs
   opened from each list's last card (PCW.formDialog). Each field is a label
   stacked over its box, in a grid: a name or a note takes the whole width,
   a picker or a number shares a row. Its own classes rather than `.row`,
   deliberately — `.row` is not styled globally in this file (pass 6b), and a
   global rule would move other screens unasked.

   `dialog input` already styles the text, number and file boxes; the select
   gets the same look here, because nothing else in a dialog styles one. The
   rule dialog is a SENTENCE (`.sentence`, shared with Incoming MIDI) and its
   pickers keep their own width so the sentence reads across. */
.audio-dialog-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
  gap: 0.75rem 1rem;
  margin-bottom: 0.75rem;
}
.audio-dialog-field { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
.audio-dialog-field > label { font-size: 0.85rem; color: var(--muted); }
.audio-dialog-wide { grid-column: 1 / -1; }
.audio-dialog select {
  width: 100%;
  padding: 0.55rem 0.65rem;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 6px;
  color: var(--text);
  font-size: 0.95rem;
}
.audio-dialog .sentence select { width: auto; max-width: 100%; }
.audio-dialog .sentence { margin-bottom: 0.75rem; }

/* The frequency range dialog carries a crib sheet of typical ranges beside
   the form (2026-09-27). Side by side where there is room; the table drops
   under the form on a phone, where two columns would be two slivers. */
.range-dialog { width: min(52rem, calc(100vw - 2rem)); max-width: none; }
.range-dialog-body {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(15rem, 19rem);
  gap: 1.25rem;
  align-items: start;
}
.range-dialog-form { min-width: 0; }
.range-typical {
  border-left: 1px solid var(--border);
  padding-left: 1.25rem;
  font-size: 0.9rem;
}
.range-typical h3 { margin: 0 0 0.5rem; font-size: 0.95rem; }
.range-typical table { width: 100%; border-collapse: collapse; }
.range-typical th, .range-typical td { padding: 0.2rem 0.3rem; text-align: left; }
.range-typical tbody th { font-weight: normal; }
.range-typical thead th { color: var(--muted); font-size: 0.8rem; }
.range-typical .num { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
.range-typical tbody tr + tr > * { border-top: 1px solid var(--border); }
@media (max-width: 40rem) {
  .range-dialog-body { grid-template-columns: 1fr; }
  .range-typical { border-left: 0; padding-left: 0; border-top: 1px solid var(--border); padding-top: 0.75rem; }
}

/* The last card on a page whose dialog needs something made on an earlier
   page — Detectors with no source, a rule with no detector. Dashed like the
   Add card it stands in for, but not a button and no accent on hover: it says
   where to go rather than offering an Add that could only be refused. */
.block-card-add-waiting {
  border-style: dashed;
  background: transparent;
  display: flex; align-items: center;
  min-height: 7.5rem;
}
.block-card-add-waiting p { margin: 0; }

/* ---------- the pedalboard builder ----------

   docs/decisions/pedalboard.md. Four classes, and only the stage is really new:
   the rows reuse .block-card, which is what every list screen in the Portal
   already draws.

   Millimetres are the unit everywhere in this feature, and the SVG's viewBox is
   in them — so the picture IS the measurement rather than an impression of one,
   and a pedal that does not fit is visibly off the edge. */

/* A pedal's colour, beside its name in a list. Square rather than round so it
   reads as an enclosure rather than as a status dot. */
.pedal-swatch {
  display: inline-block;
  width: 0.8rem;
  height: 0.8rem;
  border-radius: 3px;
  border: 1px solid var(--overlay-faint, rgba(128, 128, 128, 0.35));
  margin-right: 0.4rem;
  vertical-align: -0.1rem;
}

.pedalboard-stage {
  margin: 0.75rem 0;
  padding: 0.75rem;
  border-radius: 8px;
  background: var(--overlay-faint, rgba(128, 128, 128, 0.08));
  /* A board wider than the column scrolls rather than shrinking to illegible:
     the numbers on it stop meaning anything below a certain size. */
  overflow-x: auto;
}

#board-canvas {
  display: block;
  width: 100%;
  height: auto;
  /* Every pane the finger drags in needs this, or the browser scrolls the page
     instead of giving the pointer events to the drag — the same rule the stage
     editor's panes follow. */
  touch-action: none;
}

.pedalboard-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  align-items: center;
  margin-top: 0.5rem;
}

/* Drawn by pedalboard_stage.js. Grab rather than pointer, because these move. */
.pedal-shape { cursor: grab; }
.pedal-shape.is-dragging { cursor: grabbing; }
.pedal-shape.is-readonly { cursor: default; }

/* One control per pedal's face. Never a hit target — the pedal is what moves,
   and a knob that could be grabbed would make a 14px marker impossible to
   drag. */
.pedal-control { pointer-events: none; }

/* A list of a pedal's knobs and sockets, in the builder dialog. */
.control-list { list-style: none; margin: 0.5rem 0; padding: 0; }
.control-list li {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.35rem 0;
  border-bottom: 1px solid var(--overlay-faint, rgba(128, 128, 128, 0.2));
}
.control-list .control-where { color: var(--muted); font-size: 0.85rem; }

/* ── The board in 3D (pass 2) ──

   A studio backdrop behind a transparent WebGL canvas, rather than a colour
   inside the scene: the page's theme then decides it, and the board sits on it
   the way a product sits on a sweep in a photograph. Dark is the default theme
   and light overrides it, as everywhere in this file. */
.pedalboard-stage.is-3d {
  padding: 0;
  overflow: hidden;
  background: radial-gradient(ellipse at 50% 32%, #3b404b 0%, #1f2229 58%, #121418 100%);
}
:root[data-theme="light"] .pedalboard-stage.is-3d {
  background: radial-gradient(ellipse at 50% 32%, #fdfcf9 0%, #e9e5de 58%, #d3cec5 100%);
}

#board-3d {
  display: block;
  width: 100%;
  /* Tall enough to look at, never taller than a laptop screen. */
  height: clamp(320px, 58vh, 640px);
  touch-action: none;
  cursor: grab;
}
#board-3d.is-orbiting { cursor: grabbing; }
/* The picked pedal is the one a press will move (pass 3). */
#board-3d.is-over-picked { cursor: move; }
#board-3d.is-dragging { cursor: grabbing; }

/* ── ON A PHONE THE HEIGHT FOLLOWS THE WIDTH ──
   The camera frames the board ACROSS, so a tall narrow canvas puts a thin strip
   of board in the middle of a column of empty backdrop — measured at 375 wide,
   the canvas was 285 by 471 and the board a sliver. A board is wider than it is
   deep, so the picture should be too. */
@media (max-width: 640px) {
  #board-3d { height: clamp(220px, 72vw, 420px); }
}

/* While the 3D view is still deciding whether it can draw, the flat drawing is
   held back rather than flashed up and replaced. The script gives up waiting
   after a few seconds and shows it. */
.pedalboard-stage.is-waiting #board-canvas { visibility: hidden; }

.pedalboard-view {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem 1rem;
  align-items: center;
  margin-top: 0.4rem;
}

/* ── [hidden] HAS TO BE RESTATED, AND IT IS NOT DECORATION ──
   An element's `hidden` attribute ties with any class or id rule that sets
   `display`, and loses to it on specificity — lighting.md records a sign-in
   card and a whole console both showing because of exactly this. #board-canvas
   is `display: block` above, so hiding the flat drawing would otherwise do
   nothing at all. */
#board-canvas[hidden],
#board-3d[hidden],
.pedalboard-view[hidden] { display: none; }

/* ── PEDALBOARD PASS 4: PICTURES, EMPTY STATES, TIPS ──────────────────────── */

/* A 3D picture of a board or a pedal at the top of its card, drawn by
   pedalboard_pictures.js. The dark ground shows while it is drawn, and the
   picture fades in, as the Lighting Scenes list's do. */
.pedal-thumb {
  display: block;
  aspect-ratio: 8 / 5;
  max-width: 100%;
  overflow: hidden;
  border-radius: calc(var(--radius) - 2px);
  background: radial-gradient(ellipse at 50% 32%, #3b404b 0%, #1f2229 58%, #121418 100%);
}
:root[data-theme="light"] .pedal-thumb {
  background: radial-gradient(ellipse at 50% 32%, #fdfcf9 0%, #e9e5de 58%, #d3cec5 100%);
}
.pedal-thumb img {
  display: block; width: 100%; height: 100%; object-fit: cover;
  opacity: 0; transition: opacity 0.25s;
}
.pedal-thumb img.is-drawn { opacity: 1; }

/* An empty list: an example picture beside what to do first. Side by side on a
   wide screen, stacked on a phone. */
.pedal-empty {
  display: grid;
  grid-template-columns: minmax(0, 20rem) minmax(0, 1fr);
  gap: 1rem 1.25rem;
  align-items: center;
  padding: 1rem 0;
}
.pedal-empty[hidden] { display: none; }
.pedal-empty-text h3 { margin: 0 0 0.4rem; }
.pedal-empty-text p { margin: 0 0 0.5rem; }
@media (max-width: 640px) {
  .pedal-empty { grid-template-columns: minmax(0, 1fr); }
}

/* An empty board, said over the picture of it. */
.pedalboard-stage { position: relative; }
.pedalboard-empty {
  position: absolute;
  left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  display: flex; flex-direction: column; align-items: center; gap: 0.5rem;
  padding: 0.9rem 1.2rem;
  border-radius: var(--radius);
  background: rgba(14, 15, 18, 0.72);
  color: #f1ede6;
  text-align: center;
  pointer-events: none;
}
.pedalboard-empty > * { pointer-events: auto; }
.pedalboard-empty p { margin: 0; }
.pedalboard-empty button { width: auto; margin: 0; }
.pedalboard-empty[hidden] { display: none; }


/* The share view's invitation to start a board of one's own. */
.pedalboard-invite { border-left: 3px solid var(--accent); }

/* ── PEDALBOARD PASS 5: IMPORT AND THE CATALOGUE ──────────────────────────── */

.badge-warn { background: var(--warning-faint, var(--overlay-faint)); color: var(--warning, #b7791f); }

.import-dialog { width: min(46rem, 94vw); }
.import-dialog textarea {
  width: 100%;
  font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
  font-size: 0.82rem;
}
.import-ask { margin: 0.25rem 0 0.9rem; }
.import-ask summary { cursor: pointer; color: var(--accent); }
.import-result { margin-top: 0.75rem; }
.import-result[hidden], .import-problems[hidden] { display: none; }
.import-summary { font-weight: 600; margin: 0 0 0.5rem; }
.import-pictures { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: 0.5rem; }
.import-pictures .pedal-thumb { width: 100%; }
.import-pictures .pedal-thumb.is-small { width: 9.5rem; }
.import-warnings { margin: 0; padding-left: 1.2rem; color: var(--muted); font-size: 0.88rem; }
.import-problems { margin: 0.75rem 0 0; padding-left: 1.2rem; color: var(--danger, #c0392b); font-size: 0.9rem; }

.catalogue-filter { margin-top: 1.25rem; max-width: 24rem; }
.block-card.is-retired { opacity: 0.6; }
.block-card[hidden] { display: none; }

/* ── PEDALBOARD PASS 6: THE ADD-A-PEDAL TRAY ──────────────────────────────── */
#tray-dialog { width: min(52rem, 94vw); }
.tray-filters { display: flex; flex-wrap: wrap; gap: 0.5rem 0.9rem; }
.tray-filters .field { flex: 1 1 11rem; margin: 0; }
.tray-list { grid-template-columns: repeat(auto-fill, minmax(12.5rem, 1fr)); max-height: 60vh; overflow-y: auto; }
.tray-card { gap: 0.35rem; padding: 0.55rem 0.6rem; }
.tray-card .pedal-thumb { aspect-ratio: 8 / 5; }
.tray-card .block-card-actions button { width: auto; margin: 0; }
.tray-card[hidden] { display: none; }

/* ── PEDALBOARD PASS 8: THE PEDAL EDITOR ──────────────────────────────────── */
.pedal-design-grid { display: grid; grid-template-columns: minmax(0, 1fr) 20rem; gap: 1rem 1.25rem; align-items: start; }
@media (max-width: 900px) { .pedal-design-grid { grid-template-columns: minmax(0, 1fr); } }
.pedal-palette { display: flex; flex-wrap: wrap; gap: 0.35rem; align-items: center; margin-bottom: 0.5rem; }
.pedal-palette button { width: auto; margin: 0; padding: 0.3rem 0.6rem; font-size: 0.85rem; }
.pedal-design-stage { margin-top: 0; }
#design-3d { display: block; width: 100%; height: clamp(320px, 62vh, 680px); touch-action: none; cursor: grab; }
.pedal-design-panel section { margin-bottom: 1.1rem; }
.pedal-design-panel h2 { margin-top: 0; }
.pedal-design-panel input, .pedal-design-panel select { width: 100%; }
.pedal-design-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 0 0.6rem; }
.pedal-design-trio { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0 0.6rem; }
.pedal-design-panel [data-for][hidden], #control-panel[hidden] { display: none; }
.design-control-list { list-style: none; margin: 0; padding: 0; }
.design-control-list li { padding: 0.15rem 0.3rem; border-radius: 4px; }
.design-control-list li.is-picked { background: var(--overlay-faint, rgba(240, 173, 78, 0.15)); outline: 1px solid #f0ad4e; }

/* ── PEDALBOARD PASS 9: COLOURS, PRINTING, THE FLAT VIEW ─────────────────── */
.colour-row { display: flex; align-items: center; gap: 0.6rem; }
.colour-row input[type="color"] { width: 3rem; height: 2rem; padding: 0; }
.print-set { border: 1px solid var(--border); border-radius: var(--radius); padding: 0.4rem 0.7rem 0.2rem; margin: 0 0 0.7rem; }
.print-set legend { padding: 0 0.3rem; font-weight: 600; }
.print-set.is-picked { outline: 2px solid #f0ad4e; }
.pedalboard-view label.check { margin-left: 0.6rem; }

/* ---------- the settings sheet (pedalboard pass 16) ----------
   THE THEME'S COLOURS ON SCREEN, PAPER AND INK ON PAPER. Until 2026-09-21 the
   sheet and every card on it were cream paper and dark ink in every theme, so
   that a dark sheet would never print black. That made them the one thing in
   the Portal that ignored the theme — a cream slab in the middle of a neon
   chart. Asked for by Jeff: "we need amp and pedal setting cards to match the
   styling". So on screen they take the theme's tokens, the way a song tile
   does, and the @media print block at the end of this section puts the paper
   back. The printing concern was always about paper, and that is the only
   place it now applies.

   The hand-drawn look is pedalboard_sheet.js, which strokes in currentColor
   so the drawing is whatever colour the card's text is. */
.board-mode { display: flex; gap: 0.5rem; margin: 0 0 0.75rem; }
.board-mode button { width: auto; margin: 0; }
.board-mode button.is-current { background: var(--accent); color: var(--on-accent, #fff); border-color: var(--accent); }
.is-sheet-mode #stage, .is-sheet-mode #view-controls, .is-sheet-mode #board-readout,
.is-sheet-mode .pedalboard-actions { display: none !important; }
.pedal-sheet { background: var(--surface); color: var(--text); }
.pedal-sheet-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem 2rem; }
.pedal-sheet-title {
  font-family: "Bradley Hand", "Segoe Print", "Chalkboard SE", "Ink Free", "Marker Felt", "Comic Sans MS", cursive;
  color: var(--text);
}
.pedal-sheet-title { margin: 0; font-size: 1.6rem; }
.pedal-sheet-body { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; margin: 1rem 0; }
.pedal-sheet-card { margin: 0; break-inside: avoid; }
.pedal-sheet-pedal { display: block; max-width: 100%; height: auto; }

/* ---------- one pedal's sheet: a header and two columns ----------
   Asked for by Jeff, 2026-09-17: "lay it out as though you have two columns
   with a header that spans both columns. The left column is to display the
   controls... The right column is to list each control with their value. That
   way there is two ways for the user to see their settings."

   Both halves are drawn by pedalboard_sheet.js from the same data, so a number
   on the right and a pointer on the left cannot disagree. */
/* It is one of the CARDS the rest of the system uses — Jeff, 2026-09-17: "can
   we consolidate it a bit further by changing font sizes, etc. so that it can
   be displayed as one of our typical cards we use elsewhere in the system?
   ideally with a color band on the left that the user can specify in the design
   screen."

   The band is the pedal's OWN colour, set as --pedal-colour by the renderer, so
   nothing new had to be asked for on the designer: it is the colour the board
   already draws it in. The padding compensation is .song-card-vibed's, for its
   reason — without it the content shifts sideways by the width of the band. */
.pedal-sheet-card {
  flex: 1 1 22rem; max-width: 30rem;
  padding: 0.7rem 0.85rem 0.8rem;
  border: 1px solid var(--border);
  border-left: 0.6rem solid var(--pedal-colour, #5b6472);
  padding-left: calc(0.85rem - 0.6rem + 1px);
  border-radius: var(--radius);
  /* Ground and ink SET here rather than inherited, still — the card is dropped
     into places that are not this screen (a song guide, a performance view, a
     Conductor's offline file), and a card that took only its text colour from
     around it could land as dark ink on a dark ground. What changed on
     2026-09-21 is where the two colours come from: the theme, as a song tile's
     do, not a fixed cream and navy. Ground is --bg for the reason it is on a
     tile: these sit on a card, and a card-coloured card vanishes into it. */
  background: var(--bg);
  color: var(--text);
}
.pedal-sheet-pedal-head {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.1rem 0.6rem;
  border-bottom: 1px solid var(--border);
  padding-bottom: 0.25rem; margin-bottom: 0.55rem;
  font-family: "Bradley Hand", "Segoe Print", "Chalkboard SE", "Ink Free", "Marker Felt", "Comic Sans MS", cursive;
}
.pedal-sheet-pedal-name { font-size: 1.15rem; line-height: 1.15; }
.pedal-sheet-pedal-maker { font-size: 0.78rem; color: var(--muted); }
.pedal-sheet-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem 1rem; align-items: start; }
@media (max-width: 560px) { .pedal-sheet-cols { grid-template-columns: 1fr; } }
.pedal-sheet-drawing { min-width: 0; }
/* Card-sized by default: a sheet in a list, or later in a song guide, is a
   thing somebody glances at. The screen where it is being EDITED gives it more
   room below. */
.pedal-sheet-card .pedal-sheet-pedal { max-height: 11rem; margin: 0 auto; }
/* Name, leader, value — a written line, so it reads as the same sheet the
   drawing is on rather than as a table dropped beside it. */
.pedal-sheet-list {
  list-style: none; margin: 0; padding: 0;
  font-family: "Bradley Hand", "Segoe Print", "Chalkboard SE", "Ink Free", "Marker Felt", "Comic Sans MS", cursive;
}
.pedal-sheet-list li { display: flex; align-items: baseline; gap: 0.3rem; font-size: 0.92rem; padding: 0.08rem 0; }
.pedal-sheet-list .n { white-space: nowrap; }
/* The leader and the write-on rule are the ink at part strength, so they
   follow whichever theme the card is drawn in. The spans hold no text, so the
   opacity dims nothing but the line. */
.pedal-sheet-list .dots { flex: 1; border-bottom: 2px dotted currentColor; opacity: 0.4; transform: translateY(-0.25em); }
.pedal-sheet-list .v { white-space: nowrap; font-weight: 700; }
/* A control the preset says nothing about gets a rule to write on — which is
   what a blank sheet printed off a board has always been for. */
.pedal-sheet-list li.is-blank .v { min-width: 3.6rem; border-bottom: 2px solid currentColor; opacity: 0.5; }
/* AN AMP'S SHEET IS A FRONT PANEL (amps pass B). A row of a dozen knobs does
   not fit the left half of a pedal's card, so the drawing takes the whole width
   and the settings are written underneath, in as many columns as fit. The card
   (and the preset wrapper round it) takes a whole line of the list. */
.pedal-sheet-card.is-panel { flex: 1 1 100%; max-width: 48rem; }
.preset-item:has(.pedal-sheet-card.is-panel) { flex: 1 1 100%; max-width: 48rem; }
.pedal-sheet-card.is-panel .pedal-sheet-cols { grid-template-columns: 1fr; }
.pedal-sheet-card.is-panel .pedal-sheet-pedal { width: 100%; max-height: 9rem; }
.pedal-sheet-card.is-panel .pedal-sheet-list { columns: 3 11rem; column-gap: 1.5rem; }
.pedal-sheet-card.is-panel .pedal-sheet-list li { break-inside: avoid; }

/* ---------- what a pedal is SET TO (pedalboard pass 17) ----------
   The presets ARE the sheets (2026-09-17): a list of settings-sheet cards, and
   pressing one opens the dialog that sets it. So this section is now mostly
   about the list and that dialog; the sheet itself is the card above, which a
   board's sheet and a song guide draw the same way. */
.pedal-sheet-toolbar { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 0.5rem 1rem; }
.pedal-sheet-toolbar h2 { margin: 0; }
.pedal-sheet-toolbar p { margin: 0; }
#presets-body { list-style: none; padding: 0; }
/* The wrapper is positioned so the press target can cover the sheet, and the
   sheet inside keeps its own card chrome. */
.preset-item { position: relative; display: flex; flex: 1 1 22rem; max-width: 30rem; }
.preset-item .preset-sheet { display: flex; width: 100%; }
.preset-item .pedal-sheet-card { width: 100%; max-width: none; }
/* PRESSING THE SHEET IS WHAT OPENS IT, so the target is the whole card rather
   than a small button on it -- which is what a card is on a phone. It is a real
   button underneath, so it is reachable by keyboard and announced as an action;
   `inset: 0` is what stretches it over the card. */
.preset-open {
  position: absolute; inset: 0; z-index: 1;
  width: auto; margin: 0; padding: 0;
  background: none; border: 0; border-radius: var(--radius);
  cursor: pointer;
}
.preset-open:hover { background: rgba(35, 48, 59, 0.05); }
.preset-open:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; }
/* The dialog that sets one. Wider than the ordinary dialog because it holds a
   row per control, and scrollable because a multi-effects pedal has twenty. */
.wide-dialog { max-width: 42rem; width: calc(100vw - 2rem); }
.set-dialog-head { display: flex; flex-wrap: wrap; align-items: baseline; justify-content: space-between; gap: 0.3rem 1rem; }
.set-dialog-head h2 { margin: 0; }
.set-dialog-head .block-card-actions { border-top: 0; padding-top: 0; }
#set-dialog .preset-control-list { max-height: 60vh; overflow-y: auto; }
/* ONE pedal, drawn as big as the space allows.
   A board's sheet draws every pedal at a common scale so a page of them can be
   compared; this screen shows ONE, and at its own 3.2 px/mm a 74 mm pedal used
   a third of the column with the rest left blank. Jeff, 2026-09-17: "we should
   save some real estate for our handwritten drawings". The SVG has a viewBox,
   so width alone scales it — the height cap is what stops a tall pedal pushing
   the controls off the screen. */
.preset-control-list { list-style: none; margin: 0; padding: 0; }
.preset-control { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 1rem; padding: 0.4rem 0; border-bottom: 1px solid var(--border); }
.preset-control-name { display: flex; flex-direction: column; min-width: 9rem; flex: 1 1 9rem; }
.preset-control-name label { margin: 0; }
.preset-control-set { display: flex; align-items: center; gap: 0.6rem; }
.preset-control-set select { margin: 0; width: auto; min-width: 9rem; }
/* Wide enough for "100%", so a row does not resize as it is turned -- the
   fixed-height rule the audio meters follow, sideways. */
.preset-value { min-width: 3.2rem; font-variant-numeric: tabular-nums; }

/* ══════════════════════════════════════════════════════════════════════════
   THE FREE CHORD AND SCALE PAGES  (pcw_portal/chordscale.py)

   Public pages, so everything here is laid out for somebody who arrived from a
   search result and has never seen the Portal: one column, big targets, and no
   control that needs a hover to explain itself.
   ══════════════════════════════════════════════════════════════════════════ */

/* The instrument and position switchers. LINKS rather than a picker, so they
   work with no JavaScript and a crawler follows them. */
.tool-necks {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: 0.35rem 0.6rem;
  margin: 0 0 1rem;
}
.tool-necks-label {
  color: var(--muted); font-size: 0.7rem;
  text-transform: uppercase; letter-spacing: 0.08em;
}
.tool-neck {
  padding: 0.3rem 0.7rem;
  border: 1px solid var(--border); border-radius: 999px;
  color: var(--accent); text-decoration: none;
  font-size: 0.85rem; line-height: 1.4;
}
.tool-neck:hover, .tool-neck:focus-visible {
  border-color: var(--accent); background: var(--tint-accent-soft);
}
/* The one in force is TEXT, not a link to the page somebody is already on — a
   link that does nothing is a link somebody presses twice. */
.tool-neck.is-on {
  color: var(--accent); border-color: var(--accent);
  background: var(--tint-accent-soft); font-weight: 600;
}

/* One fingering: the picture, what it is called, and how it is written. */
.tool-voicings {
  list-style: none; margin: 0 0 0.6rem; padding: 0;
  display: flex; flex-wrap: wrap; gap: 0.8rem;
}
.tool-voicing {
  flex: 0 0 auto; width: 9rem;
  padding: 0.6rem 0.4rem;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--overlay-faint);
  text-align: center;
}
.tool-voicing-shape .chorddiagram { max-width: 100%; height: auto; }
.tool-voicing-name { margin: 0.4rem 0 0; font-size: 0.9rem; font-weight: 600; }
/* Monospaced, because a fingering is read a character at a time and a
   proportional font pushes "x" and "10" out of line with each other. */
.tool-voicing-frets {
  margin: 0.15rem 0 0;
  font-family: ui-monospace, Consolas, monospace;
  font-size: 0.85rem; color: var(--accent);
}
.tool-voicing-meta { margin: 0.15rem 0 0; font-size: 0.7rem; color: var(--muted); }

/* ── THE SCALE TOOL ── (2026-09-21) The neck takes the whole width, with a
   picker at the start of each string, placed by percentage from
   scalediagram's geometry; the choices sit below it. */
.scale-tool-board { display: flex; align-items: stretch; gap: 0.3rem; }
.scale-tool-tuning { position: relative; flex: 0 0 2.3rem; }
.scale-tool-tuning .chord-tool-string {
  left: 0; transform: translateY(-50%); width: 2.3rem;
}
.scale-tool-diagram { flex: 1 1 auto; min-width: 0; }
.scale-tool-diagram .sd { display: block; width: 100%; height: auto; max-width: none; }
.scale-tool-choices { margin-top: 1rem; }
.scale-tool-choices .chord-tool-choice { flex: 1 1 11rem; max-width: 16rem; }
.scale-tool-blurb { margin: 0.4rem 0 0; color: var(--muted); max-width: 44rem; }
/* A scale on a keyboard is marked with dots, as on the fretboard: an
   outlined dot for each note, a solid one for the root. */
.kbdiagram .kb-dot { fill: #fdfcf9; stroke: var(--accent); stroke-width: 1.6; }
.kbdiagram .kb-dot.is-root { fill: var(--accent); }
.kbdiagram .kb-dotname {
  fill: #2a2622; font-size: 7px; font-weight: 700; font-family: inherit; pointer-events: none;
}
.kbdiagram .kb-dot.is-root + .kb-dotname { fill: #fff; }
.kbdiagram .kb-dot.is-special { fill: var(--special); stroke: var(--special); }
.kbdiagram .kb-dot.is-special + .kb-dotname { fill: #fff; }
.scale-tool-special { margin: 0.6rem 0 0; font-size: 0.9rem; }
.scale-tool-special::before {
  content: ""; display: inline-block; width: 0.75em; height: 0.75em; margin-right: 0.4em;
  border-radius: 50%; background: var(--special); vertical-align: -0.05em;
}

/* The chords index starts with the instrument (2026-09-21). */
.tool-start-title { font-size: 1rem; margin: 1rem 0 0.5rem; }
.tool-starts {
  list-style: none; margin: 0; padding: 0;
  display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); gap: 0.6rem;
}
.tool-start {
  display: flex; flex-direction: column; gap: 0.15rem; padding: 0.7rem 0.9rem;
  border: 1px solid var(--border); border-radius: var(--radius);
  text-decoration: none; color: var(--text);
}
.tool-start span { font-size: 0.8rem; color: var(--muted); }
.tool-start:hover, .tool-start:focus { border-color: var(--accent); background: var(--tint-accent-soft); }

/* ── THE CHORD TOOL ── (Jeff, 2026-09-21)
   Four choices in a row, then the diagram with a picker over each string, and
   the reverse lookup beside it. The board is a fixed width so the string
   pickers, placed by percentage from render()'s geometry, sit over their
   strings at every screen size. */
.chord-tool-choices {
  display: flex; flex-wrap: wrap; gap: 0.6rem 1rem; margin: 0.8rem 0 1rem;
}
.chord-tool-choice { display: flex; flex-direction: column; gap: 0.2rem; font-size: 0.85rem; flex: 1 1 8rem; max-width: 14rem; }
.chord-tool-choice span { color: var(--muted); }
.chord-tool-choice select { width: 100%; }
.chord-tool-stage { display: flex; flex-wrap: wrap; gap: 1rem 2rem; align-items: flex-start; }
/* The chord page: the choices first on a phone; on a landscape screen the
   diagram on the left and the choices on the right (Jeff, 2026-09-21). */
.chord-tool-main { display: flex; flex-direction: column; gap: 1rem; }
@media (orientation: landscape) and (min-width: 40rem) {
  .chord-tool-main {
    display: grid; gap: 0.5rem 2.5rem; align-items: start;
    grid-template-columns: auto minmax(0, 1fr);
    grid-template-areas: "board choices" "board side";
  }
  .chord-tool-main > .chord-tool-board { grid-area: board; }
  .chord-tool-main > .chord-tool-choices {
    grid-area: choices; margin: 0;
    display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
  }
  .chord-tool-main .chord-tool-choice { max-width: none; }
  .chord-tool-main > .chord-tool-side { grid-area: side; }
}
.chord-tool-side .chord-tool-choice { margin-bottom: 0.8rem; }
.chord-tool-board { flex: 0 0 auto; }
.chord-tool-tuning { position: relative; height: 2rem; }
.chord-tool-string {
  position: absolute; top: 0; transform: translateX(-50%);
  width: 2.1rem; padding: 0.1rem 0; font-size: 0.8rem; text-align: center;
  text-align-last: center; cursor: pointer;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: var(--overlay-faint); color: var(--text);
  -webkit-appearance: none; appearance: none;
}
.chord-tool-string:hover, .chord-tool-string:focus { border-color: var(--accent); }
.chord-tool-diagram .chorddiagram { width: 100%; max-width: none; height: auto; display: block; }
.chord-tool.is-identifying .chord-tool-diagram { cursor: pointer; }
.chord-tool.is-identifying .chord-tool-diagram .chorddiagram {
  outline: 2px dashed var(--accent); outline-offset: 4px; border-radius: 4px;
}
.chord-tool-side { flex: 1 1 16rem; min-width: 0; }
.chord-tool-frets code, .chord-tool-heard code {
  font-family: ui-monospace, Consolas, monospace; color: var(--accent);
}
.chord-tool-lookup { margin: 0.4rem 0; }
.chord-tool-window { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; align-items: center; }
.chord-tool-names { list-style: none; margin: 0.4rem 0 0; padding: 0; display: grid; gap: 0.35rem; }
.chord-tool-name {
  width: 100%; text-align: left; padding: 0.4rem 0.6rem; cursor: pointer;
  border: 1px solid var(--border); border-radius: var(--radius);
  background: none; color: var(--text); font: inherit;
}
.chord-tool-name strong { margin-right: 0.4rem; }
.chord-tool-name.is-best { border-color: var(--accent); background: var(--tint-accent-soft); }
.chord-tool-name:hover { border-color: var(--accent); }
.chord-tool-named { display: grid; gap: 0.2rem; }
.chord-tool-open { font-size: 0.85rem; padding-left: 0.6rem; }
.chord-tool-neck { margin: 0 0 0.4rem; font-size: 0.85rem; color: var(--muted); }

/* A row of links to related pages — other roots, other qualities, other
   scales. These are what make the set findable, so they are big enough to be
   pressed on a phone rather than being fine print. */
.tool-chips + .tool-chips { margin-top: 1rem; }
.tool-chips-title {
  margin: 0 0 0.35rem;
  font-size: 0.7rem; color: var(--muted); font-weight: 600;
  text-transform: uppercase; letter-spacing: 0.08em;
}
.tool-chip-list { display: flex; flex-wrap: wrap; gap: 0.35rem; margin: 0; }
.tool-chip {
  display: inline-block;
  padding: 0.25rem 0.6rem;
  border: 1px solid var(--border); border-radius: var(--radius);
  color: var(--accent); text-decoration: none;
  font-size: 0.9rem; line-height: 1.5;
}
.tool-chip:hover, .tool-chip:focus-visible {
  border-color: var(--accent); background: var(--tint-accent-soft);
}
.tool-more { margin: 1rem 0 0; font-size: 0.9rem; }

/* The chords index: a root, its plain major drawn once, and its chords. */
.tool-root-head { display: flex; align-items: center; gap: 1rem; }
.tool-root-head h2 { margin: 0; }
.tool-root-shape { flex: none; width: 4.5rem; }
.tool-root-shape .chorddiagram { max-width: 4.5rem; height: auto; }
.tool-root .tool-chip-list { margin-top: 0.6rem; }
/* A twelve-fret neck is wider than a phone. Scrolling the FIGURE is right
   where scrolling the page is not — the diagram is the thing being read. */
.tool-scale .sd-figure { overflow-x: auto; }
.tool-scale .tool-chip-list { margin-top: 0.6rem; }

@media print {
  /* Paper and ink for the settings sheet and every pedal or amp card,
     whichever theme is on screen: a dark card prints as a block of black. The
     values are the cream and navy the sheet used everywhere before it followed
     the theme (see "the settings sheet" above). The drawing is currentColor,
     so setting the ink here sets its lines too. */
  .pedal-sheet, .pedal-sheet-card { background: #fbf8f1 !important; color: #23303b !important; }
  .pedal-sheet-card { border-color: #c9c3b6 !important; box-shadow: none !important; }
  .pedal-sheet-pedal-head { border-bottom-color: rgba(35, 48, 59, 0.35) !important; }
  .pedal-sheet-pedal-maker, .pedal-sheet-title { color: #23303b !important; }
  body.is-printing-sheet { background: #fff; }
  body.is-printing-sheet .topbar, body.is-printing-sheet .guest-banner,
  body.is-printing-sheet .breadcrumb, body.is-printing-sheet .flashes,
  body.is-printing-sheet main > *:not(#sheet-card),
  body.is-printing-sheet .pedal-sheet-actions { display: none !important; }
  body.is-printing-sheet #sheet-card { box-shadow: none; border: 0; background: #fff; }
}


/* ── A fixture profile's design, as JSON (2026-09-22) ──
   The paste box on the profiles page until the designer screens arrive. Wide
   and monospaced because it is read as a document; the refusal list keeps its
   line breaks, one field per line, or twenty errors run into one paragraph. */
.design-dialog { max-width: 52rem; }
.design-json {
  width: 100%;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.8rem;
  line-height: 1.35;
  tab-size: 2;
  white-space: pre;
}
.design-errors { white-space: pre-line; }

/* ── THE FIXTURE DESIGNER ── (2026-09-22, fixture designer passes 4 to 6)
   The parts on the left, the views and the preview on the right. On a narrow
   screen they stack, views first, because the picture is what a change is
   checked against. */
.designer-page { max-width: none; }
.designer-head { display: flex; flex-wrap: wrap; justify-content: space-between;
                 align-items: flex-end; gap: 0.6rem 1.2rem; }
.designer-head h1 { margin-bottom: 0.1rem; }
.designer-actions { display: flex; flex-wrap: wrap; gap: 0.4rem 0.6rem; align-items: center; }
.designer-msg { white-space: pre-line; }
.designer { display: grid; grid-template-columns: minmax(16rem, 22rem) 1fr;
            gap: 1rem; margin-top: 0.8rem; align-items: start; }
@media (max-width: 900px) {
  .designer { grid-template-columns: 1fr; }
  .designer-main { order: -1; }
}
.designer-side { display: flex; flex-direction: column; gap: 0.8rem; }
.designer-panel { border: 1px solid var(--border); border-radius: var(--radius);
                  padding: 0.6rem 0.75rem; background: var(--surface, var(--bg)); }
.designer-panel h2 { font-size: 1rem; margin: 0; }
.designer-panel-head { display: flex; flex-wrap: wrap; gap: 0.4rem 0.9rem;
                       align-items: center; margin-bottom: 0.4rem; }
.designer-add { display: flex; gap: 0.6rem; margin-left: auto; }
.designer-tree, .designer-tree ul { list-style: none; margin: 0; padding: 0; }
.designer-tree ul { padding-left: 1rem; border-left: 1px dashed var(--border); margin-left: 0.45rem; }
.designer-tree button {
  display: flex; gap: 0.4rem; align-items: center; width: 100%; text-align: left;
  font: inherit; font-size: 0.88rem; padding: 0.15rem 0.35rem; cursor: pointer;
  background: none; color: var(--text); border: 1px solid transparent; border-radius: 4px;
}
.designer-tree button:hover { border-color: var(--border); }
.designer-tree button[aria-current="true"] { border-color: var(--accent, #3b82f6);
  background: var(--overlay-faint); }
.designer-tree .kind { color: var(--text-muted); font-size: 0.75rem; min-width: 2.6rem; }
.designer-tree .swatch { width: 0.7rem; height: 0.7rem; border-radius: 2px;
  border: 1px solid var(--border); flex: none; }
.designer-props-grid { display: grid; grid-template-columns: auto 1fr; gap: 0.3rem 0.5rem;
                       align-items: center; font-size: 0.88rem; }
.designer-props-grid > label { color: var(--text-muted); }
.designer-props-grid .triple { display: flex; gap: 0.3rem; }
.designer-props-grid .triple input { width: 4.6rem; min-width: 0; }
.designer-props-grid input[type="number"] { width: 5.5rem; }
.designer-props-grid .checks { display: flex; flex-wrap: wrap; gap: 0.2rem 0.7rem; }
.designer-props-actions { display: flex; flex-wrap: wrap; gap: 0.5rem 0.9rem; margin-top: 0.6rem; }
.designer-view-bar { display: flex; flex-wrap: wrap; gap: 0.4rem 1rem; align-items: center;
                     margin-bottom: 0.4rem; }
.designer-page .stage-layout-group button {
  font: inherit; font-size: 0.85rem; padding: 0.15rem 0.6rem; cursor: pointer;
  background: none; color: var(--text-muted); border: 1px solid var(--border);
  border-radius: 999px;
}
.designer-page .stage-layout-group button[aria-pressed="true"] { color: var(--text); border-color: var(--text); }
.designer-views { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; }
.designer-view { margin: 0; min-width: 0; }
.designer-view figcaption { display: flex; justify-content: space-between; align-items: center;
                            font-size: 0.8rem; color: var(--text-muted); margin-bottom: 0.2rem; }
.designer-canvas { height: max(14rem, calc((100vh - 26rem) / 2)); }
.designer-view[data-view="room"] .designer-canvas { background: #000; }
.designer-views[data-views="two"]:not(.is-max) .designer-view:not([data-view="room"]) { display: none; }
.designer-views[data-views="two"]:not(.is-max)[data-left="front"] .designer-view[data-view="front"],
.designer-views[data-views="two"]:not(.is-max)[data-left="side"] .designer-view[data-view="side"],
.designer-views[data-views="two"]:not(.is-max)[data-left="top"] .designer-view[data-view="top"] { display: block; }
.designer-views[data-views="two"] .designer-canvas { height: max(18rem, calc(100vh - 24rem)); }
.designer-views.is-max { grid-template-columns: 1fr; }
.designer-views.is-max .designer-view { display: none; }
.designer-views.is-max .designer-view.is-max { display: block; }
.designer-views.is-max .designer-canvas { height: max(20rem, calc(100vh - 22rem)); }
@media (max-width: 700px) {
  .designer-views { grid-template-columns: 1fr; }
}
.designer-preview { margin-top: 0.8rem; }
.designer-dials { display: flex; flex-wrap: wrap; gap: 0.6rem 0.9rem; }
.designer-dial { display: flex; flex-direction: column; align-items: center; gap: 0.15rem;
                 width: 6.2rem; font-size: 0.78rem; text-align: center; }
.designer-dial label { line-height: 1.15; min-height: 2.3em; }
.designer-dial output { font-variant-numeric: tabular-nums; color: var(--text-muted); }
.designer-dial input[type="range"] { width: 100%; }
.designer-radio { border: 0; padding: 0; margin: 0; display: flex; gap: 0.8rem; }
/* The house lights switch sits in the room view's caption, beside maximize. */
.designer-view figcaption .designer-house { margin-left: auto; margin-right: 0.6rem; font-size: 0.8rem; }

/* ── ATMOSPHERICS: THE STACK EDITOR ─────────────────────────────────────────
   The preview sits beside the layers on a wide screen and above them on a
   narrow one, because on a phone a canvas next to a column of controls is two
   things too small to use. */
.atmos-edit { display: grid; gap: 1rem; }
@media (min-width: 60rem) {
  .atmos-edit { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; }
  /* Sticky, so the picture stays in view while somebody works down a long
     stack. It is the feedback for every control below it. */
  .atmos-preview-panel { position: sticky; top: 1rem; }
}

/* 16:9, because that is what a projector is. Black whatever the theme: it is a
   picture of something projected, and a pale ground would make every beam read
   as washed out on the light theme. */
.atmos-preview {
  display: block; width: 100%; aspect-ratio: 16 / 9;
  background: #000; border-radius: 6px; border: 1px solid var(--border);
}

.atmos-mix { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: flex-end; }
.atmos-params, .atmos-drives { margin-top: 0.75rem; }
.atmos-sub {
  margin: 0 0 0.35rem; font-size: 0.78rem; font-weight: 600;
  letter-spacing: 0.06em; text-transform: uppercase; color: var(--muted);
}
.atmos-val {
  min-width: 2.75rem; text-align: right; font-variant-numeric: tabular-nums;
  color: var(--text);
}
/* One control per line. `.labelled-field` is inline-flex, which is right where
   several sit in a row and wrong here: a column of them ran together as
   "0.46Speed" and "1Grid colour" -- a label that wraps away from its own box
   ends up beside somebody else's, which is the failure portal.css's own note on
   .labelled-field warns about. Measured in the harness, 2026-09-23. */
.atmos-params .labelled-field,
.atmos-drives .labelled-field {
  display: flex;
  margin-bottom: 0.5rem;
}
.atmos-params .labelled-field > label,
.atmos-drives .labelled-field > label {
  flex: 0 0 7rem;
  color: var(--muted);
}
.atmos-params .labelled-field > input[type="range"] { flex: 1 1 auto; min-width: 0; }
.atmos-drives .labelled-field > select { flex: 0 1 12rem; }
.atmos-drives .labelled-field > .field-note { flex: 1 1 auto; margin-left: 0.5rem; }
/* The mix row DOES sit several to a line, so it keeps the inline behaviour. */
.atmos-mix .labelled-field > label { color: var(--muted); }

/* ── ATMOSPHERICS: A LAYER IS A CARD, AND ADDING ONE IS A GALLERY ── (2026-09-24)
   Asked for by Jeff: the layers as cards with a still of what each visual does,
   opened by a tap to every setting it has; and adding one from a gallery of
   visuals rather than a drop-down. The stills are .scene-thumb boxes put there
   by the lighting lists' picture queue (scene_pictures.js), drawn by
   atmos_pictures.js.

   A closed card is one row -- the still small at the left, the name and a line
   of how it is mixed beside it -- so a stack of six fits the panel. An OPEN card
   takes the whole width of the list: its dials and drive pickers were laid out
   for the full panel, and squeezed into one grid cell beside a closed card they
   would wrap into nonsense. */
.atmos-layers { grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); }
.atmos-layer.is-open { grid-column: 1 / -1; }
.atmos-layer-top { display: flex; align-items: center; gap: 0.6rem; min-width: 0; }
.atmos-layer-top .scene-thumb.is-small { flex: none; width: 6rem; cursor: pointer; }

/* The name and the summary are one button: the whole of the card's face except
   the picture, which opens it too. Plain text, so it reads as the card rather
   than as a button sitting on one. */
.atmos-layer-toggle {
  display: flex; align-items: center; gap: 0.5rem;
  flex: 1 1 auto; min-width: 0;
  width: auto; margin: 0; padding: 0.2rem 0;
  background: none; border: 0; border-radius: 0;
  color: var(--text); font: inherit; text-align: left; cursor: pointer;
}
.atmos-layer-toggle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.atmos-layer-toggle .block-card-title { flex: 1 1 auto; }
/* A drawn chevron rather than a glyph, so it cannot come out as a control
   character the way an escaped triangle once did on the Conductor's console. */
.atmos-layer-chevron {
  flex: none; width: 0.55rem; height: 0.55rem;
  border-right: 2px solid var(--muted); border-bottom: 2px solid var(--muted);
  transform: rotate(-45deg); transition: transform 0.15s;
}
.atmos-layer.is-open .atmos-layer-chevron { transform: rotate(45deg); }
.atmos-layer:hover { border-color: var(--accent); }
.atmos-layer-body { border-top: 1px solid var(--border); padding-top: 0.6rem; }
.atmos-layer-body[hidden] { display: none; }

/* The gallery: wider than an ordinary dialog, because it is two dozen
   pictures, and its list scrolls inside it so Cancel stays in reach. */
dialog.atmos-gallery { width: min(62rem, calc(100vw - 2rem)); max-height: calc(100vh - 2rem); }
.atmos-gallery-list {
  grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
  max-height: calc(100vh - 16rem); overflow-y: auto;
  margin-top: 0.75rem;
}
.atmos-visual { cursor: pointer; }
.atmos-visual:hover { border-color: var(--accent); background: var(--tint-accent-faint); }
.atmos-visual-pick {
  display: flex; flex-direction: column; align-items: flex-start; gap: 0.2rem;
  width: 100%; margin: 0; padding: 0;
  background: none; border: 0; color: var(--text);
  font: inherit; text-align: left; cursor: pointer;
}
.atmos-visual-pick:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.atmos-visual.is-unavailable { opacity: 0.55; cursor: default; }
.atmos-visual.is-unavailable:hover { border-color: var(--border); background: var(--bg); }
.atmos-visual-pick:disabled { cursor: default; }

/* ── FIXTURE PROFILES, LIGHTING RIG AND LIGHTING GROUPS: CARDS AND DIALOGS ──
   (2026-09-24)

   Jeff: each of these screens is a list of cards, editing opens a dialog, and
   the last card adds (.block-card-add, PCW.formDialog). The forms that used
   to sit at the bottom of each page live in these dialogs now.

   .profile-dialog is the shared form-dialog shape for all three: every box
   has a LABEL above it that you can see (a placeholder vanishes the moment
   somebody types), a picker fills the width like the boxes beside it, and
   the fields sit two or three to a row on a wide screen and one to a row on
   a phone. */
.profile-dialog { max-height: calc(100vh - 2rem); overflow-y: auto; }
.profile-dialog select { width: 100%; }
.profile-dialog .dialog-fields {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
  gap: 0 0.75rem;
}
.profile-dialog .dialog-fields[hidden] { display: none; }
.profile-dialog .dialog-field { display: flex; flex-direction: column; min-width: 0; }
.profile-dialog .dialog-field.is-wide { grid-column: 1 / -1; }
.profile-dialog .dialog-field > label { margin: 0.85rem 0 0.3rem; }
/* A tick is not a text box: `dialog input` would give it a text box's padding
   and background. */
.profile-dialog .dialog-check {
  display: flex; align-items: center; gap: 0.5rem;
  margin-top: 1rem; color: var(--text);
}
.profile-dialog input[type="checkbox"],
.profile-dialog input[type="radio"] { width: 1.15rem; padding: 0; flex: none; }

/* ── THE LIBRARY, AS CARDS TO CHOOSE FROM ──
   In the copy dialog. Each entry is one <label> wrapping its radio, so the
   whole card -- picture included -- chooses it, and the chosen one wears the
   accent border every selected record has. It scrolls inside the dialog, so a
   long library does not push Copy off the bottom. */
.library-pick-set { border: 0; margin: 0.5rem 0 0; padding: 0; min-width: 0; }
.library-pick-set > legend {
  padding: 0; margin-bottom: 0.4rem; font-size: 0.85rem; color: var(--muted);
}
.library-pick {
  grid-template-columns: repeat(auto-fill, minmax(12rem, 1fr));
  max-height: 22rem;
  overflow-y: auto;
  padding: 0.15rem;
  margin: 0;
}
.library-pick .block-card { padding: 0; }
.library-pick-card {
  display: flex; flex-direction: column; gap: 0.4rem;
  margin: 0; padding: 0.6rem; height: 100%;
  color: var(--text); font-size: 0.9rem; cursor: pointer;
  border-radius: inherit;
}
.library-pick-card > input[type="radio"] { align-self: flex-start; }
.library-pick-text { display: flex; flex-direction: column; gap: 0.15rem; min-width: 0; }
.library-pick .block-card:has(input:checked) {
  border-color: var(--accent); background: var(--tint-accent-faint);
}
.library-pick .block-card:hover { border-color: var(--accent); }

/* ── A GROUP'S MEMBERS ──
   One labelled tick per fixture, in a box that scrolls rather than growing the
   dialog past the screen on a big rig. .check-item is the role editor's row. */
.group-members {
  margin: 1rem 0 0; padding: 0.4rem;
  max-height: 16rem; overflow-y: auto;
  border: 1px solid var(--border); border-radius: 6px; background: var(--bg);
}
.group-members > legend { padding: 0 0.3rem; font-size: 0.85rem; color: var(--muted); }

/* ── SONG IMPORT PASS 1 (docs/decisions/song-import.md) ─────────────────────── */
.song-import textarea { width: 100%; font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 0.82rem; }
.song-import-actions { justify-content: flex-start; }
.song-import-result, .song-import-done { margin-top: 1.5rem; }
.song-import-result[hidden], .song-import-done[hidden], #import-skipped-wrap[hidden] { display: none; }
.song-import-bulk { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.song-import-bulk button { width: auto; margin: 0; }
.song-import-table td input[type="text"] { min-width: 10rem; margin: 0; }
.song-import-table td input[type="checkbox"] { width: auto; margin: 0; }
.song-import-source { display: block; color: var(--muted); font-size: 0.75rem; }
.song-import-row.is-exists, .song-import-row.is-duplicate, .song-import-row.is-problem { color: var(--muted); }
.song-import-what { font-size: 0.85rem; min-width: 14rem; }
.song-import-notes { margin: 0.3rem 0 0; padding-left: 1.1rem; color: var(--muted); }
.song-import-notes .is-problem { color: var(--danger); }
.song-import-table td:nth-child(4), .song-import-table td:nth-child(6) { white-space: nowrap; }
/* Song import pass 2: a row's guide, and the dialog that draws it. */
.import-ask-heading { font-size: 0.95rem; margin: 1rem 0 0.25rem; }
.song-import-guide { display: block; color: var(--muted); font-size: 0.78rem; }
.song-import-look { width: auto; margin: 0.3rem 0 0; padding: 0.2rem 0.6rem; font-size: 0.78rem; }
.song-import-preview { width: min(52rem, 94vw); }
.song-import-preview-body { max-height: 65vh; overflow-y: auto; }

/* MCP pass 2: what an assistant sent, and the original beside the guide. */
.song-import-draft { margin: 0 0 1.5rem; padding: 0.9rem 1rem; border: 1px solid var(--border);
  border-left: 3px solid var(--accent); border-radius: 6px; }
.song-import-draft[hidden] { display: none; }
.song-import-draft h2 { margin: 0 0 0.4rem; font-size: 1.05rem; }
.song-import-compare.is-side-by-side { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
.song-import-compare h3 { font-size: 0.85rem; margin: 0 0 0.4rem; color: var(--muted); }
.song-import-original pre { margin: 0; max-height: 65vh; overflow: auto; white-space: pre;
  font-family: ui-monospace, SFMono-Regular, Consolas, monospace; font-size: 0.78rem;
  padding: 0.6rem; border: 1px solid var(--border); border-radius: 6px; }
.song-import-preview:has(.is-side-by-side) { width: min(80rem, 96vw); }
@media (max-width: 760px) { .song-import-compare.is-side-by-side { grid-template-columns: 1fr; } }

/* ── Songs › Vibe Assignment ──────────────────────────────────────────────────
   Songs on the left, the band's vibes on the right and sticky, so a long
   library can be scrolled with the drop targets still in reach. On a phone the
   two stack with the vibes FIRST: there a song is tapped and then a vibe, and
   the vibes are the short list. */
.vibe-assign {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(15rem, 20rem);
  gap: 1.25rem;
  align-items: start;
}
.vibe-assign h2 { margin: 0; font-size: 1rem; }
.vibe-assign-bar {
  display: flex; flex-wrap: wrap; gap: 0.5rem 1rem; align-items: center;
  margin-bottom: 0.75rem;
}
.vibe-assign-bar input[type="search"] { flex: 1 1 12rem; max-width: 20rem; margin: 0; }
.vibe-assign-bar .check-inline { align-self: center; padding-bottom: 0; }
.vibe-song-grid { grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); }

/* A tile that is a <button>: undo the page's button look, keep the tile's. */
button.vibe-song {
  width: 100%; min-height: 3.75rem; padding: 0; margin: 0;
  font: inherit; font-weight: normal; text-align: left;
  background: var(--bg); color: var(--text);
  border: 1px solid var(--border); border-radius: var(--radius);
  cursor: grab;
  /* pan-y: a vertical swipe scrolls, a sideways one picks the song up. */
  touch-action: pan-y;
  user-select: none; -webkit-user-select: none;
}
button.vibe-song:where(:hover) { background: var(--overlay-mid); }
button.vibe-song:disabled { cursor: default; opacity: 1; }
button.vibe-song .tile-body { padding: 0.55rem 0.75rem; }
.vibe-song-vibe { font-size: 0.75rem; color: var(--muted); }
button.vibe-song.is-picked { outline: 2px solid var(--accent); outline-offset: 1px; }
button.vibe-song.is-dragging { opacity: 0.35; }
button.vibe-song.is-busy { opacity: 0.6; cursor: progress; }
.vibe-ghost {
  position: fixed; left: 0; top: 0; z-index: 1000;
  pointer-events: none; opacity: 0.92;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
body.vibe-dragging, body.vibe-dragging * { cursor: grabbing !important; }

.vibe-assign-vibes {
  position: sticky; top: 1rem;
  max-height: calc(100vh - 2rem); overflow-y: auto;
}
.vibe-target-list {
  list-style: none; margin: 0.75rem 0 0; padding: 0;
  display: flex; flex-direction: column; gap: 0.5rem;
}
button.vibe-target {
  width: 100%; margin: 0; font: inherit; font-weight: normal; text-align: left;
  color: var(--text); cursor: pointer;
}
button.vibe-target:where(:hover) { background: var(--overlay-mid); }
button.vibe-target .block-card-title { flex: 1; }
.vibe-target-count {
  min-width: 2rem; text-align: right;
  font-weight: 600; font-variant-numeric: tabular-nums;
}
/* Where a drop or a tap would land. */
.vibe-assign.is-picking button.vibe-target { border-style: dashed; }
button.vibe-target.is-drop {
  border-color: var(--accent); background: var(--overlay-mid);
  outline: 2px solid var(--accent); outline-offset: 1px;
}

.vibe-dialog-head { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.75rem; }
.vibe-dialog-head h2 { margin: 0; }
.vibe-dialog-list {
  list-style: none; margin: 0; padding: 0;
  max-height: 55vh; overflow-y: auto;
}
.vibe-dialog-row {
  display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
  padding: 0.45rem 0; border-bottom: 1px solid var(--border);
}
.vibe-dialog-song { display: flex; flex-direction: column; min-width: 0; line-height: 1.3; }
.vibe-dialog-song .muted { font-size: 0.82rem; }
.vibe-dialog-song a { color: var(--text); font-weight: 600; text-decoration: none; }
.vibe-dialog-song a:where(:hover, :focus-visible) { text-decoration: underline; }
.vibe-dialog-row button { padding: 0.35rem 0.75rem; font-size: 0.85rem; white-space: nowrap; }

/* On a phone the vibes are a strip of chips stuck to the top, scrolling
   sideways, so the one a tapped song is going to stays in reach however far
   down the library it was. */
@media (max-width: 720px) {
  .vibe-assign { grid-template-columns: minmax(0, 1fr); gap: 0.75rem; }
  .vibe-assign > * { min-width: 0; }
  .vibe-assign-vibes {
    order: -1; top: 0; z-index: 5; max-height: none; overflow: visible;
    margin: 0 -0.25rem; padding: 0.4rem 0.25rem; background: var(--surface);
  }
  .vibe-assign-vibes h2 { display: none; }
  .vibe-target-list {
    flex-direction: row; overflow-x: auto; margin: 0; padding-bottom: 0.15rem;
    scrollbar-width: thin;
  }
  .vibe-target-list > li { flex: none; }
  button.vibe-target { padding: 0.4rem 0.6rem; }
  button.vibe-target .block-card-sub { display: none; }
  button.vibe-target .vibe-swatch { height: 1.1rem; }
  .vibe-target-count { min-width: 1.25rem; }
}
