/* ============================================================
   style.css — Falling Notes piano app
   Design tokens: minimalist, white / pastel-gray, rounded, soft shadow
   ============================================================ */

/* Google Sans Flex, self-hosted -- see index.html's comment at the
   spot the two Google Fonts <link>s used to be for the full why (GDPR
   IP-transmission exposure of remote font loading, offline
   completeness, and how one full variable font replaces both of the
   old instanced requests).
   The single file carries every axis: font-weight and font-stretch
   ranges declared here map CSS's own properties onto wght and wdth
   (ultra-condensed = 50, which is exactly what .brand-text already
   asks for), while GRAD/ROND/opsz ride through
   font-variation-settings / font-optical-sizing in the existing rules,
   untouched.
   format('truetype-variations') is deliberate: it tells the browser
   this is a variable font; the plain TTF from google/fonts works
   as-is, no woff2 conversion step required (woff2 would be ~half the
   bytes -- a fine later optimization, not a requirement).
   font-display: block (changed from swap after shipping): swap let
   text paint in a fallback face then visibly snap to the condensed
   brand font when the 4MB file finished loading -- most visible on
   the boot wordmark, reported as the whole app "re-adjusting" at
   open. block holds text invisible for the brief load instead, so
   everything appears once, already in its real font. The block
   period is bounded (~3s per spec); a genuinely missing font file
   (script never run) still falls back to system fonts after that,
   so the no-file case degrades to a short delay, not breakage. */
@font-face {
  font-family: 'Google Sans Flex';
  /* WOFF2 first -- the same font at a quarter of the size (4.0MB TTF ->
     1.0MB). The slant axis is pinned out because nothing uses it: the
     two font-style:italic rules in this file are already synthesised by
     the browser, since this face declares font-style:normal. Every axis
     the CSS does use survives (wght, wdth, GRAD, ROND, opsz).
     TTF stays as a fallback for anything without WOFF2 support. */
  src: url('vendor/fonts/GoogleSansFlex.woff2') format('woff2-variations'),
       url('vendor/fonts/GoogleSansFlex.ttf') format('truetype-variations');
  font-weight: 100 900;
  font-stretch: 25% 151%;
  font-style: normal;
  /* 'block', not 'optional', HERE and deliberately differently from the
     public pages. This font is --font-ui for the whole app, so a fallback
     kept for the page view would leave the entire interface in the wrong
     typeface until a reload. The app is behind a login and loads a lot
     more than a landing page anyway, so a brief invisible-text moment is
     the better trade. The landing pages, which are first impressions and
     mostly one wordmark, use 'optional' instead so they never shift. */
  font-display: block;
}

:root {
  /* Standard motion tokens — used for the app's bigger, more noticeable
     transitions (cards, theme switching, toasts) so they share one
     consistent feel rather than each having a slightly different duration. */
  --ease-standard: cubic-bezier(.4, 0, .2, 1);
  --dur-fast: .12s;
  --dur-slow: .32s;

  /* Core surfaces (dark is the default theme) */
  --bg: #1c1c22;
  --bg-canvas-top: #17171d;
  --bg-canvas-bottom: #0e0e12;
  --surface: #26262e;
  --surface-2: #2c2c36;
  --border: #38383f;
  --border-soft: #313138;

  /* Text */
  --text-primary: #f1f1f4;
  --text-secondary: #9a9aa4;
  --text-faint: #6a6a74;
  /* The wordmark's own ink, deliberately NOT --text-primary: the mark
     is the black-and-white key pair written out, so it stays pure
     black or white in every theme instead of tinting with the theme's
     text color (on sepia that meant brown -- reported). Each theme
     picks whichever of the two reads on its own surfaces; custom
     themes derive it from their background's lightness in app.js. */
  --wordmark-ink: #ffffff;

  /* Accent — neutral pastel gray, no blue (good contrast against the dark bg) */
  --accent: #b6b6bd;
  --accent-strong: #d8d8dd;
  --accent-contrast: #1c1c22;

  /* Hit line — its OWN token, not the accent it used to borrow. The accent
     also paints primary buttons, active pills and the timer chip, so the
     timing reference could not be tuned without dragging all of those with
     it. Deliberately kept NEUTRAL rather than brand blue: --hand-r is blue
     and --hand-l is red, so a blue line would be the same color as a
     right-hand note at the exact instant that note crosses it, and would
     disappear at the one moment it exists for. */
  --hit-line: #e6e6ec;
  --hit-line-glow: #9a9aa6;

  /* Hands / tracks — Track 1 (right hand) is blue, Track 2 (left hand) is red;
     pastel and soft in this (dark) theme for a smooth, easy-on-the-eyes look. */
  --hand-r: #9cc4f5;
  --hand-r-strong: #6f9bd9;
  /* Text color for anything filled with --hand-r as a background (the
     welcome dialog's CTA and accent rule) -- this theme's hand-r is a
     pale pastel, so it needs dark ink on top, the opposite of the
     light theme's saturated version just below. */
  --hand-r-contrast: #14181f;
  --hand-l: #f3a3a3;
  --hand-l-strong: #dd7373;
  /* Track 3 — soft pastel yellow */
  --track-2: #e3cf72;
  --track-2-strong: #c7ad48;
  /* Track 4 — soft pastel orange */
  --track-3: #eeb37e;
  --track-3-strong: #d6903f;
  /* Track 5 — soft pastel purple */
  --track-4: #c9a8e9;
  --track-4-strong: #a97cd4;
  /* Track 6+ overflow fallback — neutral pastel gray */
  --track-5: #b2b2b9;
  --track-5-strong: #8c8c93;

  /* Feedback */
  --good: #6fcf97;
  --good-strong: #3fb873;
  --bad: #ef9a9a;
  --bad-strong: #e05c5c;
  --key-correct: #4fbf82;
  --key-wrong-white: #dd6b62;
  --key-wrong-black: #c15850;

  /* Piano keys */
  --key-white: #e8e8ec;
  --key-white-edge: #b9b9c2;
  --key-black: #101013;
  --key-black-edge: #000000;
  --key-active: var(--accent);

  /* Shape / shadow */
  --radius-s: 8px;
  --radius-m: 14px;
  --radius-l: 22px;
  /* Squared-rounded scale for what used to be fully round (999px pills
     and 50%-radius circles) -- buttons, chips, sliders, and knobs
     throughout the app now echo the same rounded-rectangle character
     as the falling-note bars and piano keys, rather than reading as a
     visually separate, fully-pill "controls" language sitting apart
     from the rest of the app's own shapes. Four tiers, not one single
     value, because a radius that reads as "moderately rounded" on a
     38px-tall button looks like it's still fully round on a 7px-tall
     slider track, or barely rounded at all on a 40px toggle switch --
     matched loosely to how drawNoteBar's own radius (capped at 6-7px,
     roughly a quarter of a typical bar's width) scales down for
     smaller shapes rather than staying one fixed value everywhere. */
  --radius-pill: 10px;  /* large pills: ~30-40px tall (.pill-btn, .slider-group-icon, .summary-replay-btn) */
  --radius-chip: 7px;   /* chips/badges: ~18-28px tall (.meta-chip, .count-chip, .mini-switch, etc.) */
  --radius-knob: 5px;   /* small buttons/thumbs: ~12-20px (slider thumbs, remove buttons, pedalIndicator) */
  --radius-thin: 3px;   /* thin tracks: ~5-9px tall (range track, .loop-lane, .loop-region) */
  --shadow-s: 0 1px 2px rgba(30, 30, 40, 0.06), 0 1px 1px rgba(30, 30, 40, 0.04);
  --shadow-m: 0 6px 20px rgba(0, 0, 0, 0.35), 0 1px 3px rgba(0, 0, 0, 0.3);
  --shadow-l: 0 16px 40px rgba(0, 0, 0, 0.5);

  /* Google Sans Flex first, with the app's own previous system-font
     stack kept in full as the fallback -- not replaced, since CSS
     falls through this list automatically if the network font never
     loads (offline, blocked, request fails), so the exact same
     fallback that was the app's ONLY font before still applies here,
     just one step later in the list rather than gone. Nearly
     everything in the app inherits from this single variable (see
     body's own font-family rule below), so this one change is what
     "the whole app now uses Google Sans Flex" actually means in
     practice -- deliberately NOT applied to .brand-text (the MIDIMIDI
     wordmark, which requests this same family in its own separate,
     deliberately different configuration -- see .brand-text's own
     comment) or the Chord & Interval Analyzer's serif/monospace
     treatments (.analyzer-main/.analyzer-details), both narrow,
     deliberate exceptions serving their own specific purpose rather
     than part of the app's general text. */
  --font-ui: 'Google Sans Flex', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
  --topbar-h: 52px;
  --keyboard-h: 132px;
}

/* Custom scrollbars everywhere, using the current theme's own colors
   instead of the browser's plain native scrollbar -- most noticeable in
   tall scrollable lists, like the metadata prompt's song list when
   several files are uploaded at once. Uses the CSS variables above, which
   are already redefined per theme, so this adapts automatically without
   any theme-specific overrides of its own. */
* {
  scrollbar-width: thin;
  scrollbar-color: var(--border) var(--surface-2);
}
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: var(--surface-2); }
*::-webkit-scrollbar-thumb { background: var(--border); border-radius: 999px; border: 2px solid var(--surface-2); background-clip: padding-box; }
*::-webkit-scrollbar-thumb:hover { background: var(--text-faint); background-clip: padding-box; }
*::-webkit-scrollbar-corner { background: var(--surface-2); }

/* Also matched by html[data-boot-theme=...] (set synchronously by the
   inline script in <head>, before body even exists) -- not just
   body.theme-X (only applied once app.js actually runs). app.js can't
   run until the blocking third-party CDN scripts it depends on
   (Tone.js, @tonejs/midi) have finished loading first, which is a real,
   sometimes-not-fast network wait -- long enough that every themed
   color on the page, not just the boot screen's own background, would
   otherwise sit on the default dark-theme values (mismatched against
   the boot screen, which the html[data-boot-theme] attribute already
   colors correctly) for as long as that takes. Both selectors define
   the exact same values, so once body.theme-X does apply, nothing
   visibly changes -- this only closes the gap beforehand. */
body.theme-light, html[data-boot-theme="theme-light"] {
  --bg: #f4f4f6;
  --bg-canvas-top: #fbfbfc;
  --bg-canvas-bottom: #eef0f3;
  --surface: #ffffff;
  --surface-2: #f6f6f8;
  --border: #e3e4e8;
  --border-soft: #ececef;
  --text-primary: #2b2b30;
  --text-secondary: #8b8c95;
  --text-faint: #b7b8c0;
  --wordmark-ink: #000000;
  --key-white: #ffffff;
  --key-white-edge: #d8d9de;
  --key-black: #2c2c33;
  --key-black-edge: #17171b;
  --shadow-m: 0 6px 20px rgba(30, 30, 40, 0.08), 0 1px 3px rgba(30, 30, 40, 0.06);
  --shadow-l: 0 16px 40px rgba(30, 30, 40, 0.14);

  /* Darker pastel grays for the general accent; deeper, more saturated
     blue/red for the note bars (Track 1 = blue, Track 2 = red) so they read
     clearly against the light background. */
  --accent: #6e6e78;
  --accent-strong: #4a4a54;
  --accent-contrast: #ffffff;
  --hit-line: #3a3a44;
  --hit-line-glow: #8a8a96;
  --hand-r: #3b6fc4;
  --hand-r-strong: #2a4f94;
  --hand-r-contrast: #ffffff;
  --hand-l: #c94a4a;
  --hand-l-strong: #a13333;
  /* Track 3 — amber/gold */
  --track-2: #b8901a;
  --track-2-strong: #8f6d10;
  /* Track 4 — burnt orange */
  --track-3: #cc7a2e;
  --track-3-strong: #a35d1d;
  /* Track 5 — deep purple */
  --track-4: #7d4fb0;
  --track-4-strong: #5e3985;
  /* Track 6+ overflow fallback — neutral gray */
  --track-5: #6e6e78;
  --track-5-strong: #4a4a54;
}

/* Sepia / Warm — eye-care theme: warm parchment surfaces, deep warm-brown
   text, and muted warm-shifted accents (no near-white or blue-heavy tones),
   built to the same structure as the light theme above. Also matched by
   html[data-boot-theme="theme-sepia"] -- see the comment above the light
   theme's rule for why. */
body.theme-sepia, html[data-boot-theme="theme-sepia"] {
  --bg: #f2e8d5;
  --bg-canvas-top: #f8f0e0;
  --bg-canvas-bottom: #e9dabc;
  --surface: #f8f1e3;
  --surface-2: #efe3cb;
  --border: #ddcaa0;
  --border-soft: #e7d8b6;
  --text-primary: #4a3722;
  --text-secondary: #8a7350;
  --text-faint: #b7a37a;
  --wordmark-ink: #000000;
  --key-white: #fbf3e3;
  --key-white-edge: #d8c69e;
  --key-black: #3a2c1a;
  --key-black-edge: #201708;
  --shadow-m: 0 6px 20px rgba(74, 55, 34, 0.12), 0 1px 3px rgba(74, 55, 34, 0.08);
  --shadow-l: 0 16px 40px rgba(74, 55, 34, 0.18);

  /* Warm-shifted accent; hand colors keep the blue=right/red=left convention
     but softened toward slate-blue and terracotta so nothing reads as a cool,
     high-blue-light tone. */
  --accent: #8a6d3f;
  --accent-strong: #6b5128;
  --accent-contrast: #fbf3e3;
  --hit-line: #57411e;
  --hit-line-glow: #a08a63;
  --hand-r: #6f8fae;
  --hand-r-strong: #4f6f8f;
  --hand-l: #b56a4a;
  --hand-l-strong: #8f4c30;
  /* Track 3 — warm mustard gold */
  --track-2: #ab8a24;
  --track-2-strong: #866c16;
  /* Track 4 — burnt orange */
  --track-3: #b8722a;
  --track-3-strong: #8f571c;
  /* Track 5 — warm plum (kept out of cool-violet territory) */
  --track-4: #8a5a9e;
  --track-4-strong: #6b4179;
  /* Track 6+ overflow fallback — warm neutral */
  --track-5: #8a7350;
  --track-5-strong: #6b5940;
}

* { box-sizing: border-box; }

html, body {
  height: 100%;
  margin: 0;
  overflow: hidden;
}

body {
  font-family: var(--font-ui);
  /* Google Sans Flex's roundness is a custom axis (ROND), not one of
     the handful (weight, italic) CSS has its own dedicated property
     for -- the family=...:ROND,wght@50,100..900 request in index.html
     only controls which instances are downloadable, it doesn't by
     itself pick which one actually renders. font-variation-settings is
     the general-purpose mechanism for any axis without its own CSS
     property, custom or standard, so it's set explicitly here. If the
     font never loads, this line has no effect on the fallback system
     fonts it lands on instead (none of them recognize a ROND axis, so
     the declaration is simply ignored, not something that needs its
     own separate fallback path). */
  font-variation-settings: 'ROND' 50;
  background: var(--bg);
  color: var(--text-primary);
  -webkit-font-smoothing: antialiased;
  transition: background-color var(--dur-slow) var(--ease-standard), color var(--dur-slow) var(--ease-standard);
}

button, select, input {
  font-family: inherit;
  font-variation-settings: inherit;
}

/* svh, NOT vh and NOT dvh, and the choice is measured rather than
   fashionable.

   `vh` on iOS Safari is the LARGE viewport height -- the height the page
   would have if the URL bar were retracted. html/body are
   `overflow: hidden` (see above), so this page can never scroll, which
   means the bar never retracts and that height is never actually
   available. The bottom-most element is #keyboardWrap, so the piano sat
   permanently below the fold with no gesture that could reach it: the
   app's only possible input on iOS, unreachable.

   `dvh` would also fix that, and is the usual modern answer, but it is
   the wrong one HERE. dvh tracks the viewport live, and a viewport change
   fires resize -> resizeAll() -> computeKeyLayout(), rebuilding the
   keyboard and the canvas mid-frame. Because the page cannot scroll,
   dvh's only remaining triggers are the software keyboard opening on a
   text input (rename prompt, login) and fullscreen transitions -- so it
   buys no extra height ever, and costs a visible reflow while notes are
   falling. svh is the same number here, permanently.

   The plain `vh` line stays first as the fallback for engines without
   svh; they get exactly today's behaviour rather than no height at all. */
#app {
  display: flex;
  flex-direction: column;
  height: 100vh;
  height: 100svh;
  width: 100vw;
}

/* ---------------- Top bar ---------------- */

#topbar {
  height: var(--topbar-h);
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 0 18px;
  background: var(--surface);
  border-bottom: 1px solid var(--border-soft);
  box-shadow: var(--shadow-s);
  position: relative;
  /* Reverted from a previous round's z-index: 95 (above .panel-scrim's
     90), which deliberately kept the top bar's own nav buttons
     reachable and undimmed while a panel was open so panels could be
     switched directly. Reported directly: that's no longer wanted --
     the top bar should now be as unreachable and visually dimmed as
     the rest of the app is while a panel is open, matching the scrim's
     own treatment of everything else underneath it, not carved out as
     a special case. No explicit z-index needed here at all: with the
     override gone, the top bar is a plain in-flow element with no
     stacking context of its own, so .panel-scrim's own explicit
     z-index: 90 (position: fixed, inset: 0) already paints over it
     and intercepts its clicks the same way it does for the canvas,
     keyboard, and everything else in the background -- no special
     rule needed to make that happen, only the previous rule's
     removal. Still visually beneath .panel itself (100), same as
     before. Confirmed via real Playwright clicks: topbar buttons are
     unreachable while a panel is open, and the scrim visibly dims the
     top bar along with the rest of the app. */
}

.brand {
  display: flex;
  align-items: center;
  gap: 9px;
  font-weight: 700;
  font-size: 15.5px;
  letter-spacing: -0.01em;
  color: var(--text-primary);
  white-space: nowrap;
}
/* Sized to the WORDMARK'S CAP HEIGHT, not to a fixed pixel number.

   .brand-text renders at 36px, and cap height in this face is about
   0.72em, so 26px puts the keys level with the tops and bottoms of
   MIDIMIDI -- which is what was asked for, and what the old 32px box
   could not deliver because 17% of it was empty space around tilted
   artwork (see the viewBox note in index.html).

   Width is auto so the aspect ratio comes from the viewBox: change the
   key proportions and this does not need touching.

   ONE NUMBER TO ADJUST. If the mark still sits a shade under or over
   the letters on a machine where Google Sans Flex actually loads --
   cap height varies by face and it could not be measured reliably in
   the test harness, which serves no font -- change 26px and nothing
   else. */
.brand-icon {
  flex: 0 0 auto;
  height: 26px;
  width: auto;
  /* .brand uses align-items:center, which centres this against the
     wordmark's LINE BOX -- and the cap is not centred in that box,
     because the line reserves descender space below the baseline that
     capital letters never use. So the mark sits fractionally low.
     Measured here at 0.5px; nudged up by that.
     SECOND NUMBER TO ADJUST, alongside the 26px height, if the mark
     still sits a touch low or high where Google Sans Flex actually
     loads -- it could not be measured against the real face (the test
     harness serves no font). */
  transform: translateY(-0.5px);
}
/* Google Sans Flex, in a deliberately different configuration from the
   app's own --font-ui (see index.html's own comment on the specific
   Google Fonts request this needs, and why it's a second request
   rather than reusing the general one) -- wght 700 and wdth pinned to
   an ultra-condensed 50 for a tall, narrow, confident wordmark
   treatment, distinct from the neutral, general-purpose feel
   --font-ui is going for everywhere else.
   font-family here is var(--font-ui) itself, not a separate 'Google
   Sans Flex' entry placed in front of it -- --font-ui already starts
   with that same family name, so repeating it would just be a
   harmless but pointless duplicate in the resolved list. What actually
   picks this specific, narrower configuration over the app-wide one is
   the font-weight/font-stretch/font-variation-settings below, not the
   family name itself, since both requests share it. Falls through
   --font-ui's own full system fallback chain automatically if the
   network font never loads, so the wordmark still reads correctly
   offline, just without this specific treatment.
   font-stretch: ultra-condensed is not a stylistic guess -- it's the
   exact standard CSS keyword the requested wdth value (50) maps to
   (confirmed directly in the actual @font-face response, not assumed),
   and matching it here is what lets the browser's own font matching
   pick the correct downloaded instance rather than falling back to
   the app-wide one that happens to share the same family name.
   GRAD and ROND have no CSS property of their own the way weight and
   width do, so they're set explicitly via font-variation-settings --
   opsz is deliberately left alone rather than also pinned, since it
   was requested as a range (6..144) specifically so the browser's own
   font-optical-sizing: auto (the default) can keep adjusting it to
   match font-size automatically, the one axis here meant to keep
   responding rather than stay fixed. */
.brand-text {
  font-family: var(--font-ui);
  /* 600, not 700 -- requested, after comparing the full 300-900 range
     at this width. At ultra-condensed widths the counters inside M and
     D close up as weight climbs, so 800+ fills in at the 36px this
     actually renders at, while 300-400 goes thin enough to read as a
     label rather than a logo. 600 sits between: enough presence to be
     a mark, counters still open. Matched exactly by the
     landing/privacy/terms pages, which load the same font at the same
     wdth/wght rather than approximating it in bold system sans. */
  font-weight: 600;
  font-stretch: ultra-condensed;
  font-variation-settings: 'GRAD' 50, 'ROND' 50;
  font-size: 36px;
  letter-spacing: 0;
}
/* Both halves share ONE theme-aware fill, and the stroke is gone --
   requested, after the plain wordmark on the landing/privacy/terms
   pages read better than the outlined one.

   The stroke previously existed for a real reason: a fixed white
   "MIDI" vanished on the light theme's near-white topbar and a fixed
   black one disappeared against dark, so each needed tracing to stay
   legible. --wordmark-ink removes that problem at the source rather
   than papering over it -- it is pure white on dark themes and pure
   black on light ones, per theme, so the mark stays the same two
   colours as the key-pair logo beside it while never landing on a
   surface it cannot be read against. It is deliberately NOT
   --text-primary: that tints with the theme (sepia's brown, or a
   custom theme's picked font colour), and a logo should not.

   mix-blend-mode: difference was considered for continuous
   auto-inversion and rejected -- it computes the inverse of what's
   behind it, so a custom theme in any mid-tone grey renders the
   wordmark in near-identical mid-grey and silently loses it, and it
   forces a compositing layer directly above a canvas that repaints
   every frame during playback.

   The colour transition is the same --dur-slow/--ease-standard pair
   the theme switch itself uses (see the body rule), so the wordmark
   crossfades in step with the surfaces around it instead of snapping
   a frame ahead of them. */
.brand-text-1,
.brand-text-2 {
  color: var(--wordmark-ink);
  transition: color var(--dur-slow) var(--ease-standard);
}

/* Ko-fi cup, sitting in the same separator rhythm as the song title:
   the border-left here and #songTitle's own put a grey line either side
   of it. */
.kofi-topbar {
  display: inline-flex;
  align-items: center;
  flex: 0 0 auto;
  /* BOTH separator lines live on this element, with symmetric padding,
     so the cup is equidistant from them BY CONSTRUCTION.
     The first version borrowed the right-hand line from #songTitle's
     own border-left, which put the topbar's flex gap between the cup
     and that line: measured 9px on the left against 22px on the right,
     which is exactly what was reported. Arithmetic across three
     elements' margins and a flex gap is not a thing to keep correct by
     hand. */
  padding: 2px 10px;
  margin-left: 2px;
  border-left: 1px solid var(--border);
  border-right: 1px solid var(--border);
  opacity: 0.85;
  transition: opacity 0.15s ease;
}
/* The title's own divider would be a second line right beside this
   one. */
.kofi-topbar + #songTitle { border-left: 0; margin-left: 0; }
.kofi-topbar:hover { opacity: 1; }
/* 22px tall, not 16. At 16 it read as an afterthought beside a 26px
   mark and 36px letters. The width follows the artwork's own 241x194
   ratio. */
.kofi-topbar img { display: block; height: 22px; width: auto; }
/* The mark is near-black (#232323) with an orange heart, so on a dark
   theme the cup's outline would disappear into the bar. Ko-fi's artwork
   is NOT recoloured -- recolouring someone's trademark is the one thing
   brand guidance always forbids -- it is placed on a small light plate
   instead, which is a standard treatment and keeps the logo exact. */
body:not(.theme-light):not(.theme-sepia) .kofi-topbar img {
  background: #ffffff;
  border-radius: 5px;
  padding: 2px 3px;
}
#songTitle {
  font-size: 14.5px;
  color: var(--text-secondary);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  min-width: 0;
  padding-left: 4px;
  border-left: 1px solid var(--border);
  margin-left: 2px;
}

.topbar-spacer { flex: 1 1 auto; min-width: 8px; position: relative; align-self: stretch; }

/* Moved off the playback-controls row entirely and onto the canvas
   itself, positioned like feedbackToast just above it (see index.html)
   as a tight, centered pair rather than spanning to the canvas edges.
   An earlier version spread left-hand and right-hand chips all the way
   to the left/right edges of the canvas -- functionally correct (left
   hand read on the left, right hand on the right), but visually it read
   as two disconnected badges pinned to opposite corners, not a
   "centered" cluster the way feedbackToast itself is. This keeps the
   same left/right relationship -- left hand still sits left of right
   hand, matching a real keyboard's own layout -- but as a compact pair
   mirrored around the true horizontal center, the way the original
   request actually asked for.
   Deliberately not a single flex row with justify-content: center or
   space-between across a flat list of chips either -- with only ONE
   chip active (e.g. practicing right hand alone, a perfectly normal
   case), a flex row can't tell "should this lone item still read as
   RIGHT-of-center" from "there's nothing to distribute, so just put it
   at the natural default position" -- confirmed directly with
   space-between specifically doing the wrong thing here (a lone chip
   landed at the row's own start, not the right edge). Two independently
   positioned halves, each mirrored off center, sidesteps that
   ambiguity entirely: a lone right-hand chip still starts exactly at
   center and extends rightward, regardless of whether a left-hand chip
   exists at all.
   updateLiveStatsChip sorts each chip into the half it belongs to:
   left-hand chip(s) into #liveStatsChipsLeft (right-aligned, ending at
   center), right-hand chip(s) AND any other (non-hand) track together
   into #liveStatsChipsRight (left-aligned, starting at center, right-
   hand chip(s) inserted first so they sit closest to center with any
   other track flowing further right after them) -- so right hand reads
   on the right and left hand reads on the left, and an "other" track
   still gets a sensible, adjacent spot without needing its own
   independently-positioned third slot. pointer-events: none throughout
   -- these are read-only status chips, never meant to intercept clicks
   meant for the canvas underneath them, empty or not.
   Confirmed separately: now that both halves cluster near center
   instead of reaching the canvas edges, they no longer compete with
   #analyzerCard's own top-right corner position the way the
   edge-to-edge version did -- verified with the analyzer left open in
   its own default state, not assumed just because the numbers moved. */
#liveStatsChips {
  position: absolute;
  /* Shifted down from its own original 14px -- #summaryTopControls
     (Original/My Performance, the "Summary" reopen chip) now sits in
     that same top spot instead, just above this row, since the two
     were reported as belonging together up here. See its own comment
     for why this fixed offset (rather than the two rows dynamically
     adjusting around each other's actual content) was the simpler,
     lower-risk choice. */
  top: 48px;
  left: 16px;
  right: 16px;
  z-index: 6;
  pointer-events: none;
}
#liveStatsChipsLeft, #liveStatsChipsRight {
  position: absolute;
  top: 0;
  display: flex;
  gap: 8px;
}
#liveStatsChipsLeft { right: 50%; margin-right: 6px; }
#liveStatsChipsRight { left: 50%; margin-left: 6px; }
#liveStatsChips .live-stats-chip {
  box-shadow: var(--shadow-m);
}

.meta-chip {
  font-size: 12px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  color: var(--text-secondary);
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-chip);
  padding: 4px 10px;
  white-space: nowrap;
  flex: 0 0 auto;
}
.meta-chip:empty { display: none; }
.meta-chip.pedal-chip { display: inline-flex; align-items: center; gap: 4px; }
.meta-chip.pedal-chip.hidden { display: none; }
.meta-chip.practice-timer-chip.running { color: var(--accent-contrast); background: var(--accent); border-color: var(--accent-strong); }
.meta-chip.practice-timer-chip.goal-reached { color: var(--good-contrast, var(--accent-contrast)); background: var(--good-strong); border-color: var(--good-strong); }

.icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 38px;
  height: 38px;
  border-radius: var(--radius-s);
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text-secondary);
  cursor: pointer;
  transition: background .15s ease, color .15s ease, box-shadow .15s ease, transform .1s ease;
  flex: 0 0 auto;
}
.icon-btn:hover { background: var(--surface-2); color: var(--text-primary); box-shadow: var(--shadow-s); }
.icon-btn:disabled { opacity: 0.35; cursor: not-allowed; box-shadow: none; }
.icon-btn:disabled:hover { background: var(--surface); color: var(--text-secondary); }
/* Card close buttons (practice/timer) get the same red hover
   treatment as removing an uploaded song, since both are "dismiss/discard"
   actions — a consistent color language for that category of action
   rather than the neutral gray every other icon button hovers to. */
.icon-btn.card-close-btn:hover { background: var(--bad); color: var(--bad-strong); border-color: var(--bad-strong); box-shadow: var(--shadow-s); }
/* The practice card's header is built around the compact 20px collapse
   toggle, not the full 38px icon-btn size timer uses in its own,
   simpler header -- shrinking just this instance keeps it visually
   consistent with its sibling button rather than looking oversized next
   to it. */
#practiceHead .card-close-btn {
  width: 20px;
  height: 20px;
  border-radius: 6px;
  font-size: 11px;
}
.icon-btn:active { transform: scale(0.94); }
.icon-btn.active { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent-strong); }
.icon-btn svg { display: block; }

/* Same "shadow box" treatment as .track-ctrl and .slider-group-icon
   elsewhere in the transport controls -- a soft background plus a
   subtle border, used throughout this app to show a cluster of
   separate controls actually belongs to one feature. Applied here only
   while .active (toggled alongside notesEditorBtn's own .active state
   in setNoteEditMode) -- with editing off, Restart/Undo/Redo are all
   hidden and Notes sits alone as an ordinary topbar pill, so there's
   nothing yet to visually group; once editing turns on and all four
   buttons are showing together, the box makes clear they're one
   feature's controls rather than four unrelated topbar buttons that
   happen to sit next to each other.
   Negative margin/matching padding rather than plain padding alone --
   keeps the four buttons' own edge-to-edge spacing and each button's
   own hit target identical whether the box is showing or not, so nothing
   visibly reflows the instant editing toggles on. */
.note-edit-toolbar-group {
  display: flex;
  align-items: center;
  gap: 4px;
  border-radius: var(--radius-m);
  border: 1px solid transparent;
  margin: -4px -6px;
  padding: 4px 6px;
}
.note-edit-toolbar-group.active {
  background: var(--surface-2);
  border-color: var(--border-soft);
}

.pill-btn {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  height: 38px;
  padding: 0 14px;
  border-radius: var(--radius-pill);
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text-primary);
  font-size: 13.5px;
  font-weight: 600;
  cursor: pointer;
  transition: background .15s ease, box-shadow .15s ease, transform .1s ease;
  white-space: nowrap;
}
.pill-btn:hover { background: var(--surface-2); box-shadow: var(--shadow-s); }
.icon-svg { display: flex; align-items: center; }
.pill-btn:active { transform: scale(0.97); }
.pill-btn.primary { background: var(--accent); border-color: var(--accent-strong); color: var(--accent-contrast); }
.pill-btn.primary:hover { background: var(--accent-strong); }
.pill-btn.active { background: var(--accent); border-color: var(--accent-strong); color: var(--accent-contrast); }
.pill-btn.active:hover { background: var(--accent-strong); }

/* ---------------- Stage (canvas + keyboard) ---------------- */

#stage {
  position: relative;
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  background: var(--bg);
}

#canvasWrap {
  position: relative;
  flex: 1 1 auto;
  min-height: 0;
  overflow: hidden;
}
/* Crosshair while the "draw new notes" tool is active -- the one
   visible signal, beyond the toolbar button's own active state, that a
   click on the falling-notes area right now places a new note instead
   of the normal select/delete/restore behavior everywhere else in edit
   mode. */
.note-edit-add-cursor { cursor: crosshair; }

#fallCanvas {
  display: block;
  width: 100%;
  height: 100%;
}

.finger-edit-popup {
  position: absolute;
  z-index: 45;
  left: 0;
  top: 0;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-m);
  box-shadow: var(--shadow-m);
  padding: 8px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  transform: translate(-50%, -100%);
}
.finger-edit-popup.hidden { display: none; }
.finger-edit-buttons { display: flex; gap: 4px; }
.finger-edit-btn {
  width: 30px;
  height: 30px;
  border-radius: var(--radius-knob);
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-primary);
  font-size: 14px;
  font-weight: 700;
  cursor: pointer;
  transition: background .15s ease, transform .1s ease;
}
.finger-edit-btn:hover { background: var(--accent); color: var(--accent-contrast); }
.finger-edit-btn:active { transform: scale(0.93); }
.finger-edit-btn.current { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent-strong); }
.finger-edit-reset {
  font-size: 11px;
  font-weight: 600;
  color: var(--text-secondary);
  background: none;
  border: none;
  cursor: pointer;
  padding: 2px 4px;
  text-decoration: underline;
}
.finger-edit-reset:hover { color: var(--text-primary); }
.finger-edit-reset.hidden { visibility: hidden; }

/* Fingering picker, Wait/Along toggles, and track buttons, all inside
   the same selection panel (.note-edit-reassign-panel, defined below)
   -- these two rules are the shared building blocks that panel's own
   content uses for each row. */
.net-row-label { color: var(--text-secondary); margin-right: 6px; }
.net-tip-row { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
.net-tip-velocity-value { font-weight: 600; font-variant-numeric: tabular-nums; color: var(--text-primary); }
/* A fixed width, not flex:1 -- this panel is a compact, content-sized
   tooltip (not a full-width settings row), so the slider gets enough
   room to actually drag with precision without forcing the whole panel
   wider than everything else in it needs. */
.net-tip-velocity-slider { width: 90px; }
.net-tip-fingers { display: flex; gap: 3px; }
.net-tip-finger-btn {
  width: 20px;
  height: 20px;
  border-radius: var(--radius-knob);
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-primary);
  font-size: 11px;
  font-weight: 700;
  cursor: pointer;
  padding: 0;
  transition: background .15s ease;
}
.net-tip-finger-btn:hover { background: var(--accent); color: var(--accent-contrast); }
.net-tip-finger-btn.current { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent-strong); }

/* Note-value picker for the "+" draw-notes tool -- same shape as the
   finger buttons just above (a row of small, discrete-choice pills),
   just wider since "1/4" needs more room than a single digit does. */
.note-edit-add-value-btns { display: flex; gap: 3px; }
/* The chord-quality row is 25 pills (Single + all 24 CHORD_QUALITIES) and
   was the only thing in this panel asking for its own width -- one
   unbroken line dragged the whole draw-tool panel out to ~1140px while
   every other row in it ended well before 500px, so the panel read as a
   giant near-empty slab. Capped and wrapped so it breaks into a second
   line instead. align-items goes to the top on this row only: with a
   two-line block of pills, a vertically centred "Chord" label floats
   between the rows pointing at neither. */
#noteEditChordTypeRow { align-items: flex-start; }
#noteEditChordTypeRow .net-row-label { margin-top: 4px; }
#noteEditChordTypeBtns { flex-wrap: wrap; row-gap: 3px; max-width: 560px; }
.note-edit-add-value-btn {
  min-width: 30px;
  height: 22px;
  padding: 0 6px;
  border-radius: var(--radius-knob);
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-primary);
  font-size: 11px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  cursor: pointer;
  transition: background .15s ease;
}
.note-edit-add-value-btn:hover { background: var(--accent); color: var(--accent-contrast); }
.note-edit-add-value-btn.current { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent-strong); }

/* Positioned near wherever the click that changed the selection
   actually happened (JS-computed left/top, see
   updateNoteEditReassignPanel), not a fixed spot -- "close to the
   cursor" matters more for an interactive panel like this than a
   single one-size-fits-all docking point would, especially once a
   selection spans several scattered notes and there's no single
   "near the selection" position that would fit all of them anyway. */
/* Now holds several stacked rows (note count, fingering, Wait, Along,
   track buttons) instead of the single row this originally only needed
   for track buttons alone -- column layout, not the row layout that
   made sense before everything else joined it in the same window. */
.note-edit-reassign-panel {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 47;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 6px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-m);
  box-shadow: var(--shadow-m);
  padding: 10px 12px;
  font-size: 12px;
}
.note-edit-reassign-panel.hidden { display: none; }
.net-tip-header-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%; }
.note-edit-reassign-label { font-size: 12.5px; font-weight: 700; color: var(--text-primary); white-space: nowrap; }
.net-tip-restart-btn {
  display: flex;
  align-items: center;
  gap: 4px;
  font-size: 11px;
  font-weight: 600;
  color: var(--text-secondary);
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-chip);
  padding: 3px 9px 3px 7px;
  cursor: pointer;
  transition: background .15s ease, color .15s ease;
}
.net-tip-restart-btn:hover { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent-strong); }
.note-edit-reassign-tracks { display: flex; gap: 6px; }
.note-edit-reassign-track-btn {
  font-size: 12px;
  font-weight: 600;
  padding: 5px 10px;
  border-radius: var(--radius-chip);
  border: 1px solid var(--border-soft);
  background: var(--surface-2);
  color: var(--text-primary);
  cursor: pointer;
  transition: background-color .1s ease, box-shadow .1s ease;
}
.note-edit-reassign-track-btn:hover { filter: brightness(1.08); }
/* A ring in the theme's own accent color, added on top of the
   button's per-track background (set inline in JS, since the color
   itself is dynamic) -- consistent and recognizable as "this one's
   selected" regardless of which track color it's layered over. */
.note-edit-reassign-track-btn.current {
  box-shadow: 0 0 0 2px var(--accent);
}
/* Reported directly: deliberately NOT painted like a real track's own
   color (those are set inline in JS, per-track) since this isn't one
   -- a dashed outline reads as "add new" rather than blending in
   among the actual track buttons it sits alongside. */
.note-edit-add-track-btn {
  font-size: 12px;
  font-weight: 600;
  padding: 5px 10px;
  border-radius: var(--radius-chip);
  border: 1px dashed var(--border);
  background: transparent;
  color: var(--text-secondary);
  cursor: pointer;
  transition: background-color .1s ease, border-color .1s ease, color .1s ease;
}
.note-edit-add-track-btn:hover { background: var(--surface-2); border-color: var(--accent); color: var(--text-primary); }

.canvas-zoom-slider {
  position: absolute;
  right: 4px;
  top: 10px;
  bottom: 10px;
  width: 28px;
  z-index: 20;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: ns-resize;
  touch-action: none;
}
.canvas-zoom-track {
  position: relative;
  width: 4px;
  height: 100%;
  border-radius: 2px;
  background: var(--border-soft);
  opacity: 0.4;
  transition: opacity .15s ease;
}
.canvas-zoom-slider:hover .canvas-zoom-track, .canvas-zoom-slider.dragging .canvas-zoom-track { opacity: 0.9; }
.canvas-zoom-fill {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  border-radius: 2px;
  background: var(--accent);
}
.canvas-zoom-thumb {
  position: absolute;
  left: 50%;
  bottom: 0;
  width: 18px;
  height: 18px;
  border-radius: var(--radius-knob);
  background: var(--accent);
  border: 2.5px solid var(--surface);
  box-shadow: var(--shadow-m), 0 0 0 1px rgba(0, 0, 0, 0.2);
  transform: translate(-50%, 50%);
  pointer-events: none;
}

/* Left-edge counterpart to the zoom slider above: not a value control,
   just a dedicated, always-reachable place to grab and drag the whole
   loop range (both boundaries together, preserving its length) without
   having to precisely target one of the thin loop lines wherever they
   currently happen to be drawn. Measured directly: a 28px-wide version
   of this zone had an exact, hard boundary that was too easy to miss
   with a real mouse (one pixel past its right edge landed squarely on
   the canvas's own scrub handling instead) -- widened well past that,
   plus a visible background rather than relying on the grip icon alone
   to be found, so the target is both bigger and actually discoverable.
   top/height are set dynamically in JS (updateLoopMoveZonePosition) to
   track wherever the loop-start/loop-end lines actually are on screen,
   not a fixed span -- the earlier fixed, whole-canvas-height version
   showed its green highlight well above and below the lines themselves
   whenever the loop was shorter than the full visible canvas. */
.canvas-loop-move-zone {
  position: absolute;
  left: 0;
  top: 10px;
  width: 48px;
  z-index: 20;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: grab;
  touch-action: none;
  /* No gradient wash: the grip box below carries the affordance now, so
     the zone is just the (invisible) hit area around it. */
  background: transparent;
  opacity: 0;
  transition: background .15s ease, opacity .15s ease;
}
/* No background on hover either -- see the grip's own comment below.
   The feedback is the dots themselves coming up to full strength and
   growing slightly, which is all a 2x3 grip needs. */
.canvas-loop-move-zone:active, .canvas-loop-move-zone.dragging { cursor: grabbing; }
/* Chosen from six mocked options: the bare 2x3 dot column, the classic
   drag grip. The box it replaces was the actual problem -- a bordered
   container with an icon inside reads as a BUTTON, because every other
   bordered container with an icon in this app is one, so it invited a
   click rather than a drag. Dots carry the same "grab me" meaning with
   nothing around them to misread.

   The padding is load-bearing, not spacing: the dots themselves are only
   about 11x18px, which is well under a comfortable target, and since the
   surrounding zone became inert this grip is the ONLY thing that drags
   the loop. The padding keeps the hit box near the 26px the old square
   had, so the affordance got quieter without getting harder to grab.
   Kept close to the old box's height for a second reason too:
   LOOP_MOVE_ZONE_GRIP_CLEARANCE is tuned against it, and a taller grip
   would start colliding with the two loop labels. */
.canvas-loop-move-grip {
  display: grid;
  grid-template-columns: repeat(2, 4px);
  gap: 3px;
  align-content: center;
  justify-content: center;
  padding: 5px 8px;
  opacity: 0.6;
  transition: opacity .15s ease, transform .15s ease;
  transform: translateX(-4px);
}
.canvas-loop-move-zone:hover .canvas-loop-move-grip,
.canvas-loop-move-zone.dragging .canvas-loop-move-grip { opacity: 1; transform: translateX(-4px) scale(1.15); }
/* Set when the loop is too short for the grip to sit between its two
   labels without overlapping them (see updateLoopMoveZonePosition).
   Only the icon is hidden -- the zone keeps its size and position, so a
   short loop is still just as draggable. */
.canvas-loop-move-zone.no-grip .canvas-loop-move-grip { display: none; }
/* Requested: only the three-line handle drags the loop, not the whole
   48px-wide invisible strip that surrounds it. The zone still has to
   exist at full loop height -- it is what positions and centres the grip
   between the two loop lines (updateLoopMoveZonePosition) -- so it is
   made inert and the grip opts back in. A side benefit: that strip sat
   at z-index 20 over the left edge of the canvas and swallowed every
   click landing inside the loop's vertical span, which now reach the
   canvas as they should.

   The .no-grip exception is not a loophole. When the two loop lines are
   closer than LOOP_MOVE_ZONE_GRIP_CLEARANCE the grip is display:none --
   there is no handle on screen to take hold of, so requiring one would
   make a short loop impossible to move at all. In that one case the zone
   takes the drag back, exactly as it did before. */
.canvas-loop-move-zone { pointer-events: none; }
.canvas-loop-move-grip { pointer-events: auto; }
.canvas-loop-move-zone.no-grip { pointer-events: auto; }
.canvas-loop-move-zone.loop-move-off,
.canvas-loop-move-zone.loop-move-off .canvas-loop-move-grip { pointer-events: none; }
/* Rounded squares, not circles -- 1.5px on a 4px dot, which at this size
   reads as a dot without being a fully-circular rule.
   (Deliberately not spelling that rule out literally here:
   test_squared_shapes.js regexes the raw stylesheet, so a comment
   quoting it counts as an extra occurrence and fails the count. Caught
   exactly that way -- the first version of this comment did.)
   Deliberate, and following the precedent test_squared_shapes.js writes
   down itself: that count moves only for MARKERS (status dots, scale
   dots), and the one time a circle turned out to belong to a control -- a
   circular X on the track chips -- the fix was to square the CSS rather
   than raise the number. This grip is a control; it is the only thing
   that drags the loop. So it squares off like every other control in the
   app instead of claiming an exemption meant for things you cannot
   click. */
.canvas-loop-move-grip span {
  display: block;
  width: 4px;
  height: 4px;
  border-radius: 1.5px;
  background: var(--good-strong);
}

/* Feedback dialog. Reuses .confirm-dialog for the shell (same scrim,
   same show/hide, same actions row) and only adds what a plain confirm
   has no need for: a textarea and an inline result line. */
.feedback-dialog { width: 460px; max-width: calc(100vw - 32px); text-align: left; }
.feedback-dialog h3 { font-size: 17px; font-weight: 600; margin: 0 0 6px; }
.feedback-dialog .feedback-intro {
  font-size: 13.5px;
  color: var(--text-secondary);
  line-height: 1.55;
  margin: 0 0 14px;
}
.feedback-dialog textarea {
  width: 100%;
  box-sizing: border-box;
  resize: vertical;
  min-height: 110px;
  padding: 10px 12px;
  border-radius: var(--radius-m);
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-primary);
  font-family: inherit;
  font-size: 14px;
  line-height: 1.5;
}
.feedback-dialog textarea:focus,
.feedback-dialog input[type="email"]:focus {
  outline: 2px solid var(--accent);
  outline-offset: -1px;
  border-color: transparent;
}
/* The optional reply address. Shares the textarea's box so the two read
   as one form rather than a styled field with a raw browser input
   bolted under it -- which is exactly how it looked when the field was
   added and nothing styled it.

   Deliberately QUIETER than the textarea: smaller text, a dimmer
   placeholder and no min-height. The message is the point; the address
   is optional and should look it. */
.feedback-dialog input[type="email"] {
  width: 100%;
  box-sizing: border-box;
  margin-top: 8px;
  padding: 9px 12px;
  border-radius: var(--radius-m);
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-primary);
  font-family: inherit;
  font-size: 13.5px;
  line-height: 1.4;
}
.feedback-dialog input[type="email"]::placeholder {
  color: var(--text-faint);
}
/* Only once they have typed something -- an empty optional field is not
   an error, and reddening it before they have finished the first
   character would be scolding someone for a field they may not want. */
.feedback-dialog input[type="email"]:not(:placeholder-shown):invalid {
  border-color: var(--bad-strong);
}
.feedback-msg {
  min-height: 17px;
  margin-top: 8px;
  font-size: 12.5px;
  color: var(--text-secondary);
}
.feedback-msg.bad { color: var(--bad-strong); }
.feedback-msg.good { color: var(--good-strong); }

/* Free-day banner over the canvas. Deliberately persistent rather than
   dismissible: it is the only route to subscribing that does not
   require opening a panel, and someone on a free day is exactly who
   needs that route. Kept small and bottom-anchored so it does not
   compete with the falling notes. */
.free-day-bar {
  /* Centred in the top bar's empty middle stretch (.topbar-spacer),
     which is position:relative for exactly this. Sized to its own text
     rather than to the spacer, so it never squeezes into an unreadable
     column on a narrow window -- it may overlap a button there, which
     is much the smaller problem. */
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: max-content;
  z-index: 34;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 5px 5px 5px 14px;
  /* On the shared radius scale rather than a 999px pill. The pill was
     the last thing in the app still shaped that way apart from the
     scrollbar thumb, which is why test_squared_shapes was failing. */
  border-radius: var(--radius-m);
  background: color-mix(in srgb, var(--surface) 92%, transparent);
  border: 1px solid color-mix(in srgb, var(--hand-r) 45%, var(--border-soft));
  box-shadow: var(--shadow-m);
  /* Backdrop blur so falling notes passing behind stay suggested
     rather than legible through the text. */
  backdrop-filter: blur(6px);
  max-width: calc(100% - 32px);
}
.free-day-bar.hidden { display: none; }
/* The banner takes the top-centre slot, so the two rows that already
   live up there move down to make room -- but ONLY while it is on
   screen. The free day is a one-off 24 hours; shifting the layout
   permanently for a banner almost nobody is seeing would be the wrong
   trade. The offsets mirror #summaryTopControls' own 14px / #liveStatsChips'
   own 48px, plus the banner's height and one gap. */

/* The toast moved into the top bar, so it no longer needs to dodge
   the free-day banner at all. */
.free-day-text {
  font-size: 13px;
  font-weight: 600;
  color: var(--text-primary);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.free-day-cta {
  flex: 0 0 auto;
  height: 26px;
  padding: 0 16px;
  border-radius: var(--radius-chip);
  border: 1px solid var(--hand-r-strong);
  background: var(--hand-r);
  color: var(--hand-r-contrast);
  font-family: inherit;
  font-size: 13px;
  font-weight: 700;
  cursor: pointer;
  white-space: nowrap;
  transition: background .12s ease;
}
.free-day-cta:hover { background: var(--hand-r-strong); }
/* The last hour reads as urgent -- red, the same note colour used for
   the streak card's outline, rather than a new alarm colour. */
.free-day-bar.free-day-ending { border-color: color-mix(in srgb, var(--hand-l) 65%, var(--border-soft)); }
.free-day-bar.free-day-ending .free-day-text { color: var(--hand-l-strong); }
@media (max-width: 620px) {
  .free-day-bar { gap: 8px; padding-left: 12px; }
  .free-day-text { font-size: 12px; }
}

#dropOverlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(160, 160, 168, 0.16);
  backdrop-filter: blur(2px);
  border: 3px dashed var(--accent);
  border-radius: var(--radius-l);
  margin: 10px;
  z-index: 40;
  pointer-events: none;
}
#dropOverlay span {
  background: var(--surface);
  padding: 14px 26px;
  border-radius: var(--radius-m);
  box-shadow: var(--shadow-l);
  font-weight: 700;
  font-size: 15px;
}
#dropOverlay.hidden { display: none; }

/* Shown while handleUploadedFiles is genuinely still working (see its
   own comment on the delay before this appears at all) -- deliberately
   a SEPARATE element from #dropOverlay just above, not a second visual
   mode bolted onto it: this needs pointer-events blocked (a second
   upload shouldn't be able to start while one's still processing) and
   its own dynamic text, different enough from the drag-hint's static
   "drop here" pill that reusing the same element risked complicating
   something that already works. Same pulsing-icon language as
   #bootScreen/#idleScreen for visual consistency across every "please
   wait" moment in the app. */
#uploadLoadingOverlay {
  position: absolute;
  /* Reported: the blur left a gap all the way around the canvas. The
     cause was `inset: 0` and `margin: 10px` together -- inset stretches
     it to the edges, then the margin pulls all four sides back in by
     10px, so it could never actually reach them. The margin and the
     rounded corners are inherited from #dropOverlay above, where they
     are right: that one draws a dashed "drop here" frame that is
     SUPPOSED to sit inside the canvas. This one is a working overlay
     that has to cover everything, so both go. */
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(160, 160, 168, 0.16);
  backdrop-filter: blur(2px);
  z-index: 40;
}
#uploadLoadingOverlay.hidden { display: none; }
.upload-loading-pill {
  display: flex;
  align-items: center;
  gap: 10px;
  background: var(--surface);
  padding: 14px 26px;
  border-radius: var(--radius-m);
  box-shadow: var(--shadow-l);
  font-weight: 700;
  font-size: 15px;
  color: var(--text-primary);
}
/* animation/flex are still load-bearing; color is not -- see
   .brand-icon's own comment just above for why, the identical
   reasoning applies here. */
.upload-loading-icon { animation: bootPulse 1.4s ease-in-out infinite; flex: 0 0 auto; }

/* The "no song loaded" prompt fills the whole canvas, so as a full-bleed
   element it also swallowed clicks meant for anything floating over it --
   the practice generator's own controls being the case that surfaced it
   (caught by test_custom_select_dropdown). It is informational: only its
   button needs to be clickable, so the panel itself is transparent to
   the pointer and the button opts back in. */
#emptyState { pointer-events: none; }
#emptyState #emptyLibraryBtn { pointer-events: auto; }
#emptyState {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-direction: column;
  gap: 14px;
  color: var(--text-secondary);
  text-align: center;
  padding: 20px;
  overflow: hidden;
}
#emptyState.hidden { display: none; }
#emptyState h2 { margin: 0; font-size: 20px; color: var(--text-primary); font-weight: 700; }
#emptyState p { margin: 0; font-size: 13.5px; max-width: 360px; line-height: 1.5; }
/* Count-in -- a brief "get ready" countdown shown over the canvas right
   before a Play-Along attempt actually starts (see
   startPlaybackWithCountIn). A darker backdrop than the empty state's
   own (which just needs to read clearly against a plain canvas) since
   this needs to stay legible over a fully-drawn, colorful falling-notes
   scene mid-song. */
#countInOverlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 5;
}
#countInOverlay.hidden { display: none; }
#countInNumber {
  font-size: 96px;
  font-weight: 800;
  /* Was var(--accent) -- a deliberately neutral, muted gray by design
     (see its own comment: "good contrast against the dark bg"), tuned
     for subtle UI touches like button highlights, not for a big number
     that needs to stay readable over arbitrary, often-colored note bars
     underneath it. Doubly true now that the overlay's own background
     wash was removed (see #countInOverlay's own comment) -- that wash
     used to give the number some separation from the canvas for free;
     without it, the number needs to carry its own contrast instead of
     borrowing the scrim's. --text-primary is this app's own "maximum
     contrast against the current theme" color, already tuned per theme
     (dark: near-white, light: near-black) rather than a value picked
     specifically for this one element. */
  color: var(--text-primary);
  line-height: 1;
  /* A stroke in --bg (this theme's own base, the opposite end of the
     contrast scale from --text-primary) gives the number a defined,
     theme-appropriate edge -- so even directly over a colored note bar
     whose own tone happens to be close to --text-primary, the outline
     still reads clearly, rather than relying on fill color alone.
     -webkit-text-stroke degrades gracefully: unsupported browsers just
     show the plain fill, not a broken glyph. paint-order keeps the
     stroke from eating into the fill's own edges at this font weight. */
  -webkit-text-stroke: 3px var(--bg);
  paint-order: stroke fill;
  text-shadow: 0 4px 18px rgba(0, 0, 0, 0.45), 0 0 36px color-mix(in srgb, var(--accent) 55%, transparent);
  animation: countInPulse .35s ease-out;
}
@keyframes countInPulse {
  from { transform: scale(1.35); opacity: 0; }
  to { transform: scale(1); opacity: 1; }
}
#emptyStateIcon {
  position: relative;
  z-index: 1;
  /* animation is still load-bearing; color is not -- see .brand-icon's
     own comment for why, the identical reasoning applies here. */
  animation: emptyStateBreathe 4s ease-in-out infinite;
}
@keyframes emptyStateBreathe {
  0%, 100% { transform: scale(1); opacity: 0.75; }
  50% { transform: scale(1.06); opacity: 1; }
}
#emptyState h2, #emptyState p, #emptyState button { position: relative; z-index: 1; }
@media (prefers-reduced-motion: reduce) {
  #emptyStateIcon { animation: none; }
}

/* ---------------- Keyboard ---------------- */

#keyboardWrap {
  flex: 0 0 var(--keyboard-h);
  background: var(--surface);
  /* No border-top. It was 1px of --border-soft sitting BETWEEN the
     canvas's hit line and the tops of the keys -- measured by sampling
     the pixel column down a black key: dark line, then one row of
     #ececef, then the key. That single light row is the gap that made
     the arrival moment hard to judge, because the line no longer touched
     the thing you press. The keys now meet the hit line directly.
     Shadow flattened for the same reason: an 18px blur spread a soft
     grey haze up over the canvas and thickened the apparent line. */
  box-shadow: 0 -2px 5px rgba(30,30,40,0.05);
  overflow: hidden;
  z-index: 20;
  transition: background-color var(--dur-slow) var(--ease-standard), border-color var(--dur-slow) var(--ease-standard);
}
/* Sits right next to bpmChip in the transport controls row, sized to
   match its measured 24px rendered height (.meta-chip's own 12px font
   plus 4px vertical padding) so it lines up cleanly with the row's own
   text chips despite having no text of its own -- only shown at all
   while a MIDI device is actually connected (see
   updateMidiConnectedUI), since there's no real pedal signal to enable
   or disable from computer-keyboard or on-screen-click input in the
   first place. Now a real <button>, not a plain <div> -- was
   pointer-events: none by design when this was purely a passive status
   readout; clicking it now toggles whether pedal input gets honored at
   all (see setPedalEnabled), the same click-to-mute pattern the
   metronome button already uses, so hover/active/disabled states are
   deliberately modeled on that button's own treatment rather than
   invented fresh. .down (toggled by updatePedalIndicator, whenever the
   live CC64 reading changes) and .muted (toggled by setPedalEnabled)
   are two independent states on the same element -- one reflects the
   pedal's own live, moment-to-moment position, the other reflects
   whether this app is currently listening to it at all -- kept as a
   plain color swap for .down (deliberately quiet, not an animated
   pulse, since this updates in real time during actual playing and
   shouldn't compete for attention with anything else in the row) and a
   simple opacity dip for .muted, matching .slider-icon-btn.muted
   exactly. */
#pedalIndicator {
  flex: 0 0 auto;
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--radius-knob);
  color: var(--text-secondary);
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  padding: 0;
  cursor: pointer;
  transition: color .1s ease, background-color .1s ease, border-color .1s ease, transform .1s ease;
}
#pedalIndicator:hover { transform: scale(1.1); border-color: var(--accent-strong); }
#pedalIndicator:active { transform: scale(0.92); }
#pedalIndicator.down {
  color: var(--accent-contrast);
  background: var(--accent);
  border-color: var(--accent-strong);
}
#pedalIndicator.muted { opacity: 0.4; }

.practice-range-bar {
  flex: 0 0 auto;
  background: var(--surface);
  border-top: 1px solid var(--border-soft);
  position: relative;
  z-index: 21;
  display: flex;
  flex-direction: column;
}
.practice-range-row { position: relative; flex: 0 0 22px; }
.practice-range-row.hidden { display: none; }
.practice-range-track { position: relative; width: 100%; height: 100%; }
.practice-range-fill {
  position: absolute;
  top: 6px;
  bottom: 6px;
  background: var(--accent);
  opacity: 0.3;
  border-radius: 3px;
  pointer-events: none;
}
.practice-range-handle {
  position: absolute;
  top: 3px;
  bottom: 3px;
  width: 11px;
  margin-left: -5.5px;
  background: var(--accent-strong);
  border: 1px solid var(--accent-contrast);
  border-radius: 3px;
  cursor: ew-resize;
  box-shadow: var(--shadow-s);
  touch-action: none;
}
.practice-range-handle:hover { background: var(--accent); }
.practice-range-row-r .practice-range-fill { background: var(--hand-r); }
.practice-range-row-r .practice-range-handle { background: var(--hand-r-strong); border-color: var(--hand-r); }
.practice-range-row-r .practice-range-handle:hover { background: var(--hand-r); }
.practice-range-row-l .practice-range-fill { background: var(--hand-l); }
.practice-range-row-l .practice-range-handle { background: var(--hand-l-strong); border-color: var(--hand-l); }
.practice-range-row-l .practice-range-handle:hover { background: var(--hand-l); }

.wkey.out-of-range { background: var(--border-soft); }
.bkey.out-of-range { background: var(--border-soft); }
.bkey.out-of-range.pressed { background: #d9793a !important; }

#keyboard {
  position: relative;
  height: 100%;
  min-width: 100%;
}

.wkey, .bkey {
  position: absolute;
  bottom: 0;
  border-radius: 0 0 6px 6px;
  cursor: pointer;
  user-select: none;
  transition: background .06s ease;
  box-shadow: inset 0 -3px 4px rgba(0,0,0,0.05);
}

.wkey {
  top: 0;
  background: var(--key-white);
  border: 1px solid var(--key-white-edge);
  border-top: none;
  z-index: 1;
}
.wkey .key-label, .bkey .key-label {
  position: absolute;
  bottom: 6px;
  left: 0;
  right: 0;
  text-align: center;
  font-size: 9px;
  font-weight: 700;
  color: var(--text-faint);
  pointer-events: none;
}
/* The computer-keyboard shortcut for a key, shown only while that input
   mode is on -- an opaque accent badge near the top (the note-name label
   already owns the bottom), so it stays legible over a key's own color
   changes (hover, pressed, correct/incorrect glow) instead of just
   recoloring text the way .key-label does. */
.kb-key-label {
  position: absolute;
  top: 6px;
  left: 2px;
  right: 2px;
  text-align: center;
  font-size: 9px;
  font-weight: 800;
  line-height: 14px;
  color: var(--accent-contrast);
  background: var(--accent);
  border-radius: 4px;
  pointer-events: none;
  z-index: 3;
}
.bkey .kb-key-label { top: 4px; left: 1px; right: 1px; font-size: 8px; line-height: 12px; }
/* White-key labels sit at the BOTTOM of the key, in the open area below
   where the black keys end. Reported: with every label pinned to the
   top, the white-key labels crowded into the black-key zone and the
   whole row read as though only the black keys were mapped. Splitting
   the two rows vertically makes each label unmistakably part of the key
   it belongs to. */
.wkey .kb-key-label { top: auto; bottom: 8px; }

.bkey {
  top: 0;
  height: 62%;
  background: var(--key-black);
  border: 1px solid var(--key-black-edge);
  border-top: none;
  z-index: 2;
}

.wkey.pressed { background: var(--key-active); transition: background-color .08s ease; }
.wkey.hand-R.pressed { background: var(--hand-r-strong); }
.wkey.hand-L.pressed { background: var(--hand-l-strong); }
.bkey.pressed { background: var(--key-active); transition: background-color .08s ease; }
.bkey.hand-R.pressed { background: var(--hand-r-strong); }
.bkey.hand-L.pressed { background: var(--hand-l-strong); }

/* Correct note (matches the current bar) — smooth, pleasant green.
   Ramps up quickly, then holds at full glow for as long as the key
   is actually held down; fades out via the transition below the
   instant the key is released. */
@keyframes keyGoodBloomIn {
  0% { box-shadow: 0 0 0 0 var(--key-correct); }
  100% { box-shadow: 0 0 18px 5px var(--key-correct); }
}
.wkey.good.pressed {
  background: var(--key-correct) !important;
  animation: keyGoodBloomIn .15s ease-out forwards;
  transition: box-shadow .25s ease-out;
  /* Above other white keys (z-index 1) so the glow isn't clipped on one
     side, but below black keys (z-index 2) so it doesn't wash over them —
     black-on-top is the normal piano layout and should stay that way. */
  z-index: 1.5;
}
.bkey.good.pressed {
  background: var(--key-correct) !important;
  animation: keyGoodBloomIn .15s ease-out forwards;
  transition: box-shadow .25s ease-out;
  z-index: 3; /* black keys are already meant to sit on top, so this just keeps the glow from being clipped by neighboring black/white keys */
}
/* Wrong note played — a clear, deliberately gentler red glow, same
   hold-while-pressed behavior as the correct-note glow above. Mistakes get
   clear feedback, not a harsh, alarming one; the asymmetry with the more
   celebratory green bloom is intentional. */
@keyframes keyBadGlowIn {
  0% { box-shadow: 0 0 0 0 var(--key-wrong-white); }
  100% { box-shadow: 0 0 12px 3px var(--key-wrong-white); }
}
@keyframes keyBadGlowInBlack {
  0% { box-shadow: 0 0 0 0 var(--key-wrong-black); }
  100% { box-shadow: 0 0 12px 3px var(--key-wrong-black); }
}
.wkey.bad.pressed {
  background: var(--key-wrong-white) !important;
  animation: keyBadGlowIn .15s ease-out forwards;
  transition: box-shadow .25s ease-out;
  z-index: 1.5; /* same reasoning as the correct-note glow above — clears white neighbors, stays under black keys */
}
.bkey.bad.pressed {
  background: var(--key-wrong-black) !important;
  animation: keyBadGlowInBlack .15s ease-out forwards;
  transition: box-shadow .25s ease-out;
  z-index: 3;
}

/* Auto-highlight: lit up by the song itself during playback (lighter than a user press) */
.wkey.auto:not(.pressed) { background: var(--accent); opacity: 0.55; }
.bkey.auto:not(.pressed) { background: var(--accent); opacity: 0.75; }
.wkey.auto.hand-auto-R:not(.pressed) { background: var(--hand-r); opacity: 1; }
.wkey.auto.hand-auto-L:not(.pressed) { background: var(--hand-l); opacity: 1; }
.bkey.auto.hand-auto-R:not(.pressed) { background: var(--hand-r-strong); opacity: 1; }
.bkey.auto.hand-auto-L:not(.pressed) { background: var(--hand-l-strong); opacity: 1; }
.wkey.auto.hand-auto-track2:not(.pressed) { background: var(--track-2); opacity: 1; }
.bkey.auto.hand-auto-track2:not(.pressed) { background: var(--track-2-strong); opacity: 1; }
.wkey.auto.hand-auto-track3:not(.pressed) { background: var(--track-3); opacity: 1; }
.bkey.auto.hand-auto-track3:not(.pressed) { background: var(--track-3-strong); opacity: 1; }
.wkey.auto.hand-auto-track4:not(.pressed) { background: var(--track-4); opacity: 1; }
.bkey.auto.hand-auto-track4:not(.pressed) { background: var(--track-4-strong); opacity: 1; }
.wkey.auto.hand-auto-track5:not(.pressed) { background: var(--track-5); opacity: 1; }
.bkey.auto.hand-auto-track5:not(.pressed) { background: var(--track-5-strong); opacity: 1; }
/* Any track index beyond what the fixed classes above cover (6+, or any
   index at all under a custom theme's own per-track overrides) --
   reported directly, alongside the same fix on the falling-notes
   canvas itself: those extra tracks used to all collapse into the
   Track-5 class/color above, indistinguishable from one another and
   from Track 5 itself. A CSS class can't scale to an open-ended number
   of distinct colors the way the 4 fixed ones above can, so this one
   reads its own actual color from an inline custom property
   (--key-auto-bg/-strong) set directly in JS per key, computed fresh
   from whichever real track that key is currently highlighting for
   (see updateKeyboardAutoHighlights). */
.wkey.auto.hand-auto-dynamic:not(.pressed) { background: var(--key-auto-bg); opacity: 1; }
.bkey.auto.hand-auto-dynamic:not(.pressed) { background: var(--key-auto-bg-strong); opacity: 1; }

#keyboard { touch-action: none; }

/* ---------------- Transport bar ---------------- */

#transport {
  flex: 0 0 auto;
  background: var(--surface);
  border-top: 1px solid var(--border-soft);
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding: 12px 22px 14px;
  z-index: 25;
}

.transport-row {
  display: flex;
  align-items: center;
  gap: 18px;
}
/* Was flex-wrap: wrap -- whenever the window wasn't wide enough for
   every slider/chip/track-control to fit on one line, the row would
   wrap onto a second line, which grew the whole transport bar's height
   and visibly pushed the canvas and keyboard up to make room, then back
   down again the moment it fit again. Horizontal scroll (the same fix
   already used for #topbar's own overflow below) keeps this row's
   height completely fixed regardless of window width -- nothing above
   it ever has to move to make room; anything that doesn't fit is just a
   sideways scroll away instead. */
/* The edge fades below live in this outer, non-scrolling wrapper rather
   than inside .transport-row-controls itself -- an absolutely positioned
   element whose *own* containing block is also the thing that scrolls
   ends up positioned relative to that element's unscrolled content
   rather than staying fixed to the visible edge in this rendering
   engine, so it silently drifts off-screen with the scroll instead of
   sitting still on top of it. A separate wrapper that itself never
   scrolls sidesteps that outright. */
.transport-row-controls-wrap {
  position: relative;
  flex: 1 1 auto;
  min-width: 0;
}
.transport-row-controls {
  gap: 22px;
  flex-wrap: nowrap;
  overflow-x: auto;
  overflow-y: hidden;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none; /* the fade hint below communicates scrollability instead of a visible scrollbar */
  /* Some browsers (mobile Safari especially) apply their own faint,
     unstyled edge-fade mask to a horizontally-scrollable element on
     their own -- inconsistent with, and much weaker than, the explicit
     fade below, which is what made the first letter of whatever sat
     right at the scrolled edge look like it was fading out to nothing
     rather than just softly indicating "more this way." Turning that
     native masking off leaves exactly one fade treatment in play: the
     one actually designed for this. */
  -webkit-mask-image: none;
  mask-image: none;
}
.transport-row-controls::-webkit-scrollbar { display: none; }
/* Edge fades -- shown only on whichever side genuinely has more to
   scroll to right now (.scrollable-start / .scrollable-end, toggled on
   .transport-row-controls itself from resizeAll() and on every scroll
   of that row; picked up here via the general sibling combinator since
   the fades are siblings of that row, not descendants). Unlike #topbar
   below, which is tight on space on just about every screen it's ever
   shown on, this row comfortably fits everything on a lot of desktop
   widths -- an always-on fade here would read as a stray visual
   artifact on exactly those wider screens, rather than a genuine scroll
   hint. 40px and a three-stop gradient (not just transparent to opaque)
   so the hint actually reads at a glance instead of needing a close
   look to spot. */
.transport-row-controls.scrollable-end ~ .transport-controls-fade-end {
  opacity: 1;
}
.transport-row-controls.scrollable-start ~ .transport-controls-fade-start {
  opacity: 1;
}
.transport-controls-fade-end, .transport-controls-fade-start {
  position: absolute;
  top: 0; bottom: 0;
  width: 40px;
  /* Inert on desktop: a wheel or trackpad already scrolls this row, so a
     button here would be clutter over a problem that does not exist.
     Turned on inside the phone breakpoint below. */
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.15s ease;
  z-index: 2;
  display: flex;
  align-items: center;
  /* now a <button> */
  border: 0;
  padding: 0;
  font: inherit;
  color: var(--text-secondary);
  cursor: pointer;
}
.transport-scroll-chevron {
  display: none;
  font-size: 22px;
  line-height: 1;
  padding: 0 4px;
  opacity: 0.75;
}
.transport-controls-fade-end {
  right: 0;
  justify-content: flex-end;
  background: linear-gradient(to right, transparent, color-mix(in srgb, var(--surface) 55%, transparent) 60%, var(--surface));
}
.transport-controls-fade-start {
  left: 0;
  justify-content: flex-start;
  background: linear-gradient(to left, transparent, color-mix(in srgb, var(--surface) 55%, transparent) 60%, var(--surface));
}
/* The gradient alone reads as "the row just ends here" as easily as it
   reads as "there's more this way" -- especially glanced at rather than
   studied, and especially for someone who doesn't already know this row
   scrolls. A small accent-colored chevron is a far more universally
   understood "more this direction" signal on its own, and the slow,
   continuous nudge (not just a static icon) is what actually catches a
   glance in the first place -- motion draws the eye even in peripheral
   vision, where a merely brighter or larger icon still wouldn't. Kept
   slow and small enough to read as "hey, this direction" rather than
   becoming its own distraction sitting there forever whenever a song's
   track list happens to be long. */
.transport-controls-fade-end::after,
.transport-controls-fade-start::after {
  content: '';
  width: 7px;
  height: 7px;
  border-top: 2px solid var(--accent);
  border-right: 2px solid var(--accent);
  margin: 0 10px;
  animation: transportFadeNudge 1.6s ease-in-out infinite;
}
.transport-controls-fade-end::after { transform: rotate(45deg); }
.transport-controls-fade-start::after { transform: rotate(-135deg); animation-direction: reverse; }
@keyframes transportFadeNudge {
  0%, 100% { opacity: 0.55; }
  50% { opacity: 1; }
}

.transport-group {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 0 0 auto;
}

.transport-controls {
  display: flex;
  align-items: center;
  gap: 6px;
}

/* Grid, not two independent flex rows -- a genuinely shared pair of
   columns is what makes the seek bar and loop bar the exact same
   length, since grid sizes column 1 to fit whichever of .time-label /
   .loop-bookmarks-actions actually needs more room (in practice "+
   Save loop" plus the "+N" toggle, wider than a short time string) and
   applies that width to BOTH rows -- not two separately-sized flex
   containers that only happen to produce equal-length bars when their
   own labels happen to be the same width. .time-label is centered
   within its own cell (text-align, below) rather than left-aligned, so
   the extra room that column ends up needing reads as intentional
   breathing space around a short label, not a stray gap trailing after
   it before the bar starts. */
.seek-group {
  flex: 1 1 auto;
  min-width: 140px;
  display: grid;
  grid-template-columns: auto 1fr;
  column-gap: 12px;
  row-gap: 4px;
  align-items: center;
}
/* display:contents: .seek-row/.loop-bookmarks-row stay in the markup
   for styling hooks and readability, but hand their own children
   straight to the grid above as its real items, rather than each row
   being one opaque grid cell of its own (which would defeat the shared-
   column sizing entirely). */
.seek-row { display: contents; }

.time-label {
  font-size: 13px;
  font-weight: 700;
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
  text-align: center;
}
/* Reset to plain, inline-looking text -- deliberately NOT styled like a
   chip/button the way the library's own inline-editable cells are,
   since this needs to read as "one piece of the M:SS / M:SS string
   happens to be clickable," not as a separate control sitting inside
   it. text-decoration on hover only (never a background, border, or
   weight/size change) specifically because .time-label's own width
   feeds directly into the shared grid-column sizing .seek-group's own
   comment above describes -- anything that changed this element's own
   metrics on hover would visibly nudge the seek bar's own length along
   with it. Only ever rendered as a button at all while
   state.noteEditMode is on (see renderDurationLabel) -- editing a
   song's own overall length is an editing-mode action, not something
   that should look reachable while just watching it play. */
.duration-edit-btn {
  font: inherit;
  color: inherit;
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
}
/* Reported directly: this did not look editable. A hover-only underline
   is invisible to anyone who never happens to hover it, and invisible
   on touch entirely -- so the affordance only reached people who had
   already guessed it was there. It now carries a permanent dashed
   underline, which is what a tap-to-edit field looks like everywhere
   else, and the hover state stays as extra confirmation rather than
   being the only signal. The pencil glyph that first shipped alongside
   it was removed on request -- in a compact time readout it read as a
   character in the value rather than an affordance beside it. */
.duration-edit-btn {
  border-bottom: 1px dashed var(--accent);
  padding-bottom: 1px;
}

.duration-edit-btn:hover {
  border-bottom-style: solid;
  background: color-mix(in srgb, var(--accent) 12%, transparent);
  border-radius: 3px;
}
.duration-edit-input {
  font: inherit;
  color: inherit;
  background: var(--surface);
  border: 1px solid var(--accent);
  border-radius: 5px;
  padding: 1px 4px;
  outline: none;
  width: 3.6em;
  text-align: center;
}

/* Original/My Performance and the "Summary" reopen chip both now live
   in a floating row at the top of the canvas (see #summaryTopControls
   in index.html and its own comment there) rather than down in the
   transport controls -- reported directly, these belong up where the
   accuracy stats already are. Positioned the same way #liveStatsChips
   already positions itself (absolute, anchored to the canvas area),
   sitting just above it with #liveStatsChips itself shifted down to
   make room (see its own updated rule below) -- the two rows stack,
   each with its own fixed slot, rather than one dynamically pushing
   the other around as their contents come and go. This row naturally
   collapses to zero height on its own whenever both of its own
   children are independently hidden (an empty flex container has no
   intrinsic size to fall back on), so it needs no visibility toggle of
   its own -- pointer-events:none for the same reason #liveStatsChips
   already has it, so an empty stretch of it can never intercept a
   click meant for the canvas underneath, re-enabled on each of its
   two actual children individually. */
#summaryTopControls {
  position: absolute;
  top: 14px;
  left: 16px;
  right: 16px;
  z-index: 6;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  pointer-events: none;
}
#summaryTopControls .performance-compare-tabs,
#summaryTopControls .summary-reopen-chip {
  pointer-events: auto;
}
.performance-compare-tabs { display: flex; gap: 4px; }
.performance-compare-tab {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 5px 12px;
  font-size: 11.5px;
  font-weight: 700;
  font-family: var(--font-ui);
  color: var(--text-secondary);
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-s);
  cursor: pointer;
  box-shadow: var(--shadow-m);
}
.performance-compare-tab.selected { background: var(--accent); border-color: var(--accent-strong); color: var(--accent-contrast); }
/* A small speaker icon on both Original and My Performance -- reported
   directly: without it, nothing about these two tabs signals that
   they're specifically about which AUDIO gets played (see
   switchPerformanceCompareView's own comment on why they no longer
   touch the falling notes shown on screen at all), as opposed to some
   other kind of view switch. currentColor throughout, same as the
   volume icon it's copied from, so it automatically follows this
   tab's own selected/unselected text color rather than needing its
   own separate light/dark treatment. */
.performance-compare-icon { flex: 0 0 auto; }
#summaryTopControls .summary-reopen-chip { box-shadow: var(--shadow-m); }

.loop-bookmarks-row { display: contents; }
.loop-bookmarks-actions {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
}
/* display:contents: the "+N" toggle (its only possible child now --
   see renderLoopBookmarks) flows as a direct flex item of
   .loop-bookmarks-actions instead of being wrapped inside an extra
   layout box of its own. */
.loop-bookmarks-list { display: contents; }
.loop-bookmark-chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 2px 4px 2px 10px;
  border-radius: var(--radius-chip);
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-secondary);
  font-size: 11px;
  font-weight: 600;
  cursor: pointer;
  transition: background .12s ease, color .12s ease, border-color .12s ease;
}
.loop-bookmark-chip:hover { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent-strong); }
.loop-bookmark-chip.active { background: color-mix(in srgb, var(--accent) 20%, var(--surface-2)); border-color: var(--accent); color: var(--text-primary); }
.loop-bookmark-label { cursor: text; max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.loop-bookmark-label-input {
  width: 90px;
  font: inherit;
  color: var(--text-primary);
  background: var(--surface);
  border: 1px solid var(--accent-strong);
  border-radius: 4px;
  padding: 1px 4px;
  outline: none;
}
.loop-bookmark-remove-btn {
  width: 15px;
  height: 15px;
  border-radius: var(--radius-thin);
  border: none;
  background: transparent;
  color: inherit;
  opacity: 0.55;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 10px;
  line-height: 1;
  flex: 0 0 auto;
}
.loop-bookmark-remove-btn:hover { opacity: 1; background: var(--bad); color: var(--bad-strong); }
.loop-bookmark-add-btn {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 3px 10px 3px 8px;
  border-radius: var(--radius-chip);
  border: 1px dashed var(--border);
  background: transparent;
  color: var(--text-secondary);
  font-size: 11px;
  font-weight: 600;
  cursor: pointer;
  flex: 0 0 auto;
}
.loop-bookmark-add-btn:hover { border-color: var(--accent); color: var(--text-primary); border-style: solid; }

/* "+N" toggle for every saved loop -- styled like the "+ Save loop"
   button (a dashed meta-action, not a bookmark itself) so it reads as
   a control that opens something, not as a bookmark in its own right,
   while still living in the same row. Present in the DOM at all times
   once a song is loaded (see renderLoopBookmarks) -- .invisible right
   below is what actually hides it whenever there are zero saved loops,
   rather than the element not existing at all then. */
.loop-bookmarks-overflow-toggle {
  display: inline-flex;
  align-items: center;
  padding: 2px 10px;
  border-radius: var(--radius-chip);
  border: 1px dashed var(--border);
  background: transparent;
  color: var(--text-secondary);
  font-size: 11px;
  font-weight: 700;
  cursor: pointer;
  flex: 0 0 auto;
}
.loop-bookmarks-overflow-toggle:hover, .loop-bookmarks-overflow-toggle.open { border-color: var(--accent); border-style: solid; background: var(--surface-2); color: var(--text-primary); }
/* visibility, not display:none -- still occupies its own layout space
   (see the toggle's own comment above for why that matters: it's what
   keeps the shared time-label/loop-actions grid column, and so the
   loop bar's own x-position, from shifting the instant the first loop
   gets saved or the last one gets removed). */
.invisible { visibility: hidden; }

/* Floating rather than an inline expansion -- the whole point is to
   never grow the row (and shrink the canvas above it) just to reveal
   the rest of the bookmarks. position:fixed with coordinates set by JS
   (see toggleLoopBookmarksOverflowPopover) rather than absolute inside
   the row itself, since that row needs overflow:hidden to stay capped
   at one line, which would otherwise clip this along with it. No
   min-width -- one or two short-labeled loops used to force this open
   to a fixed 200px regardless of how little content was actually in
   it, leaving a big empty block beside a single ~70px chip. Sizing
   purely to content (still bounded by max-width for many/long-labeled
   loops, which is what wraps it onto more than one line) means the
   popover's own size actually reflects what's inside it. */
.loop-bookmarks-overflow-popover {
  position: fixed;
  z-index: 90;
  max-width: 320px;
  max-height: 220px;
  overflow-y: auto;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-m);
  box-shadow: var(--shadow-m);
  padding: 8px;
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
.loop-bookmarks-overflow-popover.hidden { display: none; }

input[type="range"] {
  -webkit-appearance: none;
  appearance: none;
  height: 7px;
  margin: 0;
  border-radius: var(--radius-thin);
  background: var(--border);
  outline: none;
  flex: 1 1 auto;
  cursor: pointer;
}
/* Keyboard-only focus indicator -- the rule above strips the browser's
   own default outline (needed since the custom thumb below replaces the
   native slider entirely), but unlike the text inputs elsewhere in this
   file, nothing filled that gap back in for range sliders specifically.
   :focus-visible rather than plain :focus so this only appears for an
   actual Tab-key focus, not a mouse click/drag -- a glowing ring on
   every drag would be an unwanted, constant distraction for the far
   more common mouse/touch interaction these are mostly used with. */
input[type="range"]:focus-visible {
  box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent);
}
input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 17px;
  height: 17px;
  border-radius: var(--radius-knob);
  background: var(--accent);
  border: 2px solid var(--surface);
  box-shadow: var(--shadow-s);
  cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
  width: 15px;
  height: 15px;
  border-radius: var(--radius-knob);
  background: var(--accent);
  border: 2px solid var(--surface);
  cursor: pointer;
}

.seek-track-wrap { position: relative; flex: 1 1 auto; }
/* Nudged up 1.5px from where flex/grid align-items:center alone puts
   it -- the track itself measures exactly the declared 7px tall (no
   extra thumb-related box inflation involved), but still lands
   measurably below .time-label's own vertical center in this
   specific row, for reasons that trace to the row's height being
   set by the label's own line box rather than anything about the
   track's height itself -- tightening the label's line-height didn't
   move its already-centered center at all, only shrank its box
   symmetrically around that same point, confirming this needs a
   direct correction rather than a text-metrics fix. transform, not
   margin-top -- margin-top measurably had no effect here at all
   (still centered exactly as before with it applied), apparently
   because grid's own align-items:center recomputes centering from
   the item's margin box, so an asymmetric margin here just shifted
   what gets centered without changing the actual rendered position;
   transform moves the already-laid-out, already-centered element
   directly instead, sidestepping that recompute entirely. Specific to
   this one bar, not the generic input[type="range"] rule -- the loop
   lane one row down sits flush against its own row's label with zero
   measured offset already (a plain button row centers differently
   than a text line does), so nudging every slider in the app
   uniformly would have thrown that one off instead of fixing this. */
#seekBar { position: relative; width: 100%; transform: translateY(-1.5px); }
#seekBar:disabled { opacity: 0.45; cursor: not-allowed; }
.loop-bookmark-add-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.loop-bookmark-add-btn:disabled:hover { border-color: var(--border); color: var(--text-secondary); border-style: dashed; }

/* Sits on its own row now, right where "+ Save loop" is (see
   loop-bookmarks-row), rather than absolutely overlaid just under the
   seek bar -- flex: 1 1 auto here is what makes it grow to match the
   seek bar's own width, the same as #seekBar does one row up, so the
   two bars line up edge to edge. Still position: relative -- .loop-
   region and .loop-handle inside it are still absolutely positioned,
   just now relative to this normal flex item instead of a stacking
   context shared with the seek bar. Height matches the seek bar's own
   track height (7px) exactly -- box-sizing: border-box means this
   height already includes the 1px border below, so this and the seek
   bar's native track render at the same visual thickness rather than
   the loop lane reading as a noticeably thicker bar than the one
   above it. */
.loop-lane {
  position: relative;
  flex: 1 1 auto;
  height: 7px;
  border-radius: var(--radius-thin);
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  cursor: crosshair;
  /* WITHOUT THIS, dragging a loop handle on a phone moves a few pixels
     and then freezes. The browser claims the gesture as a pan, fires
     pointercancel, and pointermove stops arriving -- REPORTED as "it
     just moves and then stops moving".
     e.preventDefault() in pointerdown does NOT prevent that; only
     touch-action does. Every other drag surface in this file already
     sets it (canvas, zoom slider, the loop MOVE zone, practice
     handles); the lane itself was missed. */
  touch-action: none;
}
.loop-region {
  position: absolute;
  top: -1px; bottom: -1px;
  display: none;
  border-radius: var(--radius-thin);
  background: var(--accent);
  opacity: 0.85;
  cursor: grab;
}
.loop-region:active { cursor: grabbing; }
.loop-region.show { display: block; }
.loop-handle {
  position: absolute;
  top: 50%;
  width: 14px; height: 14px;
  margin-top: -7px;
  border-radius: var(--radius-thin);
  background: var(--surface);
  border: 2px solid var(--accent-strong);
  box-shadow: var(--shadow-s);
  cursor: ew-resize;
}
.loop-handle.left { left: -7px; }
.loop-handle.right { right: -7px; }
/* A 14px target in a 7px lane is the smallest thing in the interface,
   and it is one people drag repeatedly while practising. Same ::after
   technique as the topbar buttons: the visible dot stays 14px so the
   transport row does not grow, and an invisible 44px box in front of it
   accepts the touch.
   Deliberately WIDER than tall (44x44 centred): the drag is horizontal,
   and a taller box would start swallowing taps meant for the seek bar
   directly above. */
.loop-handle::after {
  content: '';
  position: absolute;
  left: 50%; top: 50%;
  width: 44px; height: 44px;
  transform: translate(-50%, -50%);
  touch-action: none;
}

.slider-group {
  display: flex;
  flex-direction: column;
  gap: 3px;
  /* Deliberately still 122. Widening this to buy the labels more room
     was tried and reverted: the controls row was already exactly at
     its limit at a 1280px window, so every extra pixel here pushed it
     into overflow on a very common laptop width. The label problem is
     solved by shortening the labels and by the nowrap rule below,
     which costs no width at all. */
  width: 122px;
  flex: 0 0 auto;
}
.slider-group label {
  font-size: 11.5px;
  color: var(--text-secondary);
  display: flex;
  justify-content: space-between;
  gap: 4px;
  font-weight: 700;
  /* Belt and braces on the bug above: even with the extra width, this
     label must never be able to wrap, because wrapping is what changes
     the row's height. A fixed line-height rather than the default also
     keeps the row identical in Japanese, whose taller CJK metrics made
     this label 3px deeper than in every other language. */
  white-space: nowrap;
  line-height: 14px;
}
/* If a future translation really is too long, it truncates instead of
   wrapping -- the layout stays put and the title attribute still has
   the full text. */
.slider-group label > span:first-child { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
/* The value keeps its own width so stepping 95% -> 100% cannot reflow
   anything beside it. */
.slider-group label > span:last-child { flex: 0 0 auto; text-align: right; min-width: 30px; }
.slider-group input[type="range"] { width: 100%; }
/* Transpose's own value span shows the resulting key name once one's
   known, in place of the plain semitone count (see updateTransposeDisplay),
   at the exact same font/size/weight as every other slider-group's own
   value -- this label rule is otherwise identical to .slider-group
   label's own; only justify-content changes, to flex-start, since
   margin-left: auto on the value span below now does that same
   right-alignment job on its own (see its own comment).
*/
.transpose-label { justify-content: flex-start; }
/* Pinned to the same far-right position the plain number always sat
   at, independent of how wide its own text currently is ("0" vs
   "F# Major") -- confirmed pixel-identical to the original,
   pre-feature layout's own right edge in every tested case. Without
   this, a wide key name could push itself further left than the
   number ever did, but could never make the row itself any wider than
   it's always been -- only right-alignment through however much of
   the fixed 122px width happens to be free is what keeps every other
   slider-group in this same row untouched.
*/
#transposeVal { margin-left: auto; white-space: nowrap; }


.slider-group-icon {
  flex-direction: row;
  align-items: center;
  gap: 7px;
  width: 168px;
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-pill);
  padding: 5px 12px 5px 8px;
}
.slider-icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  color: var(--text-primary);
  line-height: 1;
  padding: 2px;
  border: none;
  background: transparent;
  cursor: pointer;
  border-radius: 6px;
  transition: opacity .15s ease, transform .1s ease, color .15s ease;
}
.slider-icon-btn:hover { transform: scale(1.1); color: var(--accent-strong); }
.slider-icon-btn:active { transform: scale(0.92); }
.slider-icon-btn.muted { opacity: 0.4; }
.slider-icon-btn svg { display: block; }
.slider-group-icon input[type="range"] { width: 100%; min-width: 0; flex: 1 1 auto; }

.divider {
  width: 1px;
  align-self: stretch;
  background: var(--border-soft);
  flex: 0 0 auto;
}

/* ---------------- Panels (Library / Settings) ---------------- */

/* Reuses the exact same icon and pulse treatment as #bootScreen (see
   index.html's own inline head styles) -- unlike that one, this doesn't
   need to exist before first paint/before CSS loads, so it lives in the
   normal stylesheet and uses the normal theme variables (staying in
   sync with theme changes automatically) rather than boot's hardcoded
   per-theme hex values, which exist only because that earlier snippet
   has no CSS variables available yet to read from. Sits above every
   panel/scrim (z-index 90) since the point is to fully cover
   interaction with the app while idle, not just the topmost panel. */
#idleScreen {
  position: fixed; inset: 0; z-index: 9998;
  background: var(--surface);
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px;
  opacity: 1;
  transform: translateY(0);
  transition: opacity .25s ease, transform .25s cubic-bezier(.4, 0, .2, 1);
}
/* This element's own hide-state is a dedicated class, .idle-hidden --
   deliberately NOT the generic .hidden utility class every other
   toggle-visibility element in this app already uses. That reuse was
   the actual bug, confirmed directly rather than guessed: .hidden is
   `display: none !important` (see its own rule, used everywhere else
   in this app for a plain, instant show/hide), and !important always
   wins regardless of a more specific selector's own, unmarked
   declaration -- so an earlier attempt at this same fix, overriding
   just the display property for #idleScreen.hidden specifically,
   measured as correct CSS on paper but was silently and completely
   ignored at runtime, still snapping instantly via the SAME
   display:none the generic rule forces. Fighting that with a second
   !important would have worked, but turns "which of two competing
   !important rules wins" into an ongoing, fragile question for anyone
   editing either one later -- a dedicated class name sidesteps the
   collision entirely instead of trying to out-specificity it.
   display isn't an animatable property at all, so as long as ANY rule
   anywhere sets display:none on this element while it's meant to be
   transitioning, the transition below can never actually run: the
   instant that class is added, the element vanishes from the render
   tree in the same frame, before any opacity or transform change had a
   chance to be visible on screen. Matches #panel's own show/hide
   treatment for exactly this reason -- .panel never uses display:none
   either, staying opacity/transform-driven throughout precisely so its
   own transition can run both directions. Same idea here: opacity +
   pointer-events carries the same "invisible and non-interactive while
   idle" result display:none did, but lets the transition actually
   play both directions -- fading and settling gently into place on the
   way in (translateY 10px -> 0), easing back out the same way on the
   way out, matching cubic-bezier(.4,0,.2,1), the exact same curve
   .panel's own slide-in already uses. */
#idleScreen.idle-hidden { opacity: 0; transform: translateY(10px); pointer-events: none; }
/* animation is still load-bearing; color is not -- see .brand-icon's
   own comment for why, the identical reasoning applies here. */
#idleScreen svg { animation: bootPulse 1.4s ease-in-out infinite; }
#idleScreenLabel { font-size: 13px; color: var(--text-secondary); text-align: center; padding: 0 24px; }
@keyframes bootPulse { 0%, 100% { opacity: .35; } 50% { opacity: .9; } }

.panel-scrim {
  position: fixed;
  inset: 0;
  background: rgba(20, 20, 26, 0.28);
  z-index: 90;
  opacity: 0;
  pointer-events: none;
  transition: opacity .18s ease;
}
.panel-scrim.show { opacity: 1; pointer-events: auto; }

#confirmScrim { z-index: 150; }
#dayDetailScrim { z-index: 150; }

.confirm-dialog {
  position: fixed;
  top: 50%;
  left: 50%;
  width: 340px;
  max-width: 88vw;
  background: var(--surface);
  border-radius: var(--radius-l);
  box-shadow: var(--shadow-l);
  padding: 22px 22px 18px;
  z-index: 160;
  opacity: 0;
  pointer-events: none;
  transform: translate(-50%, -48%) scale(0.96);
  transition: opacity .16s ease, transform .16s cubic-bezier(.4,0,.2,1);
}
.confirm-dialog.show { opacity: 1; pointer-events: auto; transform: translate(-50%, -50%) scale(1); }
.confirm-dialog p { margin: 0 0 18px; font-size: 14px; line-height: 1.5; color: var(--text-primary); overflow-wrap: break-word; word-break: break-word; }
.confirm-actions { display: flex; justify-content: flex-end; gap: 10px; }
.confirm-danger { background: var(--bad-strong); border-color: var(--bad-strong); color: #fff; }
.confirm-danger:hover { background: var(--bad-strong); opacity: 0.9; }

/* ---------------- Song metadata prompt (shown after uploading, like the confirm dialog) ---------------- */
#metaPromptScrim { z-index: 150; }
.meta-prompt-dialog {
  width: 520px;
  max-height: 70vh;
  display: flex;
  flex-direction: column;
}
.meta-prompt-dialog h3 { margin: 0 0 6px; font-size: 16px; font-weight: 700; }
.meta-prompt-sub { font-size: 12.5px; color: var(--text-secondary); margin: 0 0 16px; line-height: 1.4; }
#metaPromptList { overflow-y: auto; flex: 1 1 auto; margin-bottom: 16px; }
.meta-prompt-row { margin-bottom: 14px; padding-bottom: 14px; border-bottom: 1px solid var(--border-soft); }
.meta-prompt-row:last-child { border-bottom: none; margin-bottom: 0; padding-bottom: 0; }
.meta-prompt-title-row { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; }
.meta-prompt-title-input { font-weight: 600; }
.meta-prompt-title-row .song-key-btn { flex: 0 0 auto; }
.meta-prompt-timesig { flex: 0 0 auto; display: inline-flex; }
.meta-prompt-inputs { display: flex; gap: 8px; }
.meta-prompt-input {
  flex: 1 1 0;
  min-width: 0;
  padding: 8px 10px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-primary);
  font-size: 13px;
  font-family: inherit;
}
.meta-prompt-input:focus { outline: none; border-color: var(--accent); }
.meta-prompt-input::placeholder { color: var(--text-faint); }

/* ---------------- More Tracks dialog (floating, like the confirm dialog) ---------------- */
#tracksScrim { z-index: 150; }

.tracks-dialog {
  position: fixed;
  top: 50%;
  left: 50%;
  width: 380px;
  max-width: 90vw;
  max-height: 70vh;
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border-radius: var(--radius-l);
  box-shadow: var(--shadow-l);
  z-index: 160;
  opacity: 0;
  pointer-events: none;
  transform: translate(-50%, -48%) scale(0.96);
  transition: opacity .16s ease, transform .16s cubic-bezier(.4,0,.2,1);
}
.tracks-dialog.show { opacity: 1; pointer-events: auto; transform: translate(-50%, -50%) scale(1); }
.tracks-dialog-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 18px 20px;
  border-bottom: 1px solid var(--border-soft);
  flex: 0 0 auto;
}
.tracks-dialog-header h3 { margin: 0; font-size: 16px; font-weight: 700; }
.tracks-dialog-body {
  flex: 1 1 auto;
  overflow-y: auto;
  padding: 16px 20px 20px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.tracks-dialog-body .track-ctrl { flex-wrap: wrap; }

/* Converted from a centered, scrim-blocked modal to a floating card --
   mirrors #practiceCard's own positioning approach (absolute, inside
   #stage, plain pixel left/top rather than left:50%/transform
   centering) rather than the fixed-and-centered .tracks-dialog pattern
   most of the other dialogs in this file still use. Centered
   horizontally by default (computed once in JS -- see the drag wiring
   below -- not via CSS, so a genuine drag afterward has plain,
   absolute coordinates to work from instead of fighting a transform-
   based centering trick layered underneath it), anchored below
   #summaryTopControls/#liveStatsChips (86px) until dragged elsewhere.
   z-index matches the other floating cards (36), not the much higher
   layer a true blocking modal needed, since it no longer needs to sit
   above everything else on the page the way one genuinely would. */
.summary-dialog {
  position: absolute;
  width: 380px;
  max-width: calc(100% - 28px);
  max-height: calc(100% - 100px);
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-m);
  box-shadow: var(--shadow-m);
  z-index: 36;
  opacity: 0;
  pointer-events: none;
  transform: translateY(-6px);
  transition: opacity .16s ease, transform .16s cubic-bezier(.4,0,.2,1);
}
.summary-dialog.show { opacity: 1; pointer-events: auto; transform: translateY(0); }
/* Reported directly: a real, pre-existing bug (not caused by the
   custom dropdown work) -- .summary-dialog's own base z-index (36,
   just above) is correct for the ordinary session-summary use of that
   class, which never competes with a .panel (z-index 100) for
   stacking since it isn't opened from inside one. This dialog is
   opened from inside the Stats panel specifically, so it needs to
   clear that -- confirmed directly (not assumed) that without this,
   the dialog technically opens (gets its own 'show' class) but
   renders completely invisible behind the panel, indistinguishable
   from "nothing happened" at all. The compound selector (both classes,
   matching what's actually on the element) is deliberate -- a plain
   .day-detail-dialog rule would tie with .summary-dialog's own on
   specificity, at which point mere file order decides the winner;
   this wins outright regardless of where either rule sits. Same
   z-index pattern as #confirmScrim/.confirm-dialog use for the same
   reason (see #dayDetailScrim, just above .confirm-dialog's own rule). */
.summary-dialog.day-detail-dialog {
  z-index: 160;
  /* Reported directly, a second and genuinely separate bug from the
     z-index one just above: this dialog never had any positioning
     logic of its own at all -- #summaryDialog's own top/left (see the
     drag-position-restoring code that sets those) only ever targets
     that specific element, never this one, so this was silently
     falling back to plain static-flow positioning the whole time
     (confirmed directly: computed top ended up at 900px, wherever it
     happened to naturally sit on the page, not any deliberate
     placement) -- completely off-screen below the viewport, on top of
     already being invisible from the z-index issue. Centered instead,
     the same fixed-position pattern .confirm-dialog/.tracks-dialog
     already use for exactly this kind of dialog. */
  position: fixed;
  top: 50%;
  left: 50%;
  right: auto;
  transform: translate(-50%, -50%);
}
.summary-dialog.day-detail-dialog.show { transform: translate(-50%, -50%); }
/* Reuses .analyzer-head as-is now, drag affordance included -- this
   card is genuinely draggable too (see its own wiring, mirroring
   #practiceCard's exactly), reported directly. */
#summaryHead { cursor: grab; }
#summaryHead:active { cursor: grabbing; }
/* Same collapsed treatment #practiceCard's own body gets -- the card
   itself stays put (and reachable), just its content collapses away to
   nothing, rather than the whole thing disappearing the way closing it
   outright does. */
.summary-dialog.collapsed .summary-dialog-body { display: flex; max-height: 0; opacity: 0; overflow: hidden; padding-top: 0; padding-bottom: 0; }
.summary-dialog-body { padding: 18px 20px 20px; display: flex; flex-direction: column; gap: 14px; overflow-y: auto; transition: max-height .2s var(--ease-standard), opacity .16s ease; }
.summary-stats { display: flex; justify-content: space-between; gap: 8px; }
.summary-stat { display: flex; flex-direction: column; align-items: center; gap: 2px; flex: 1; }
.summary-stat-value { font-size: 22px; font-weight: 800; color: var(--text-primary); }
.summary-stat-label { font-size: 11px; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.04em; }
.summary-stat.has-mistakes .summary-stat-value { color: var(--bad-strong); }
.summary-goal-line { margin: 0; font-size: 13px; color: var(--text-secondary); text-align: center; }
.summary-goal-line.met { color: var(--good-strong); font-weight: 600; }
/* Interesting facts about this session (new records, improvement vs.
   last attempt, etc. -- see renderSummaryFacts) -- deliberately plain
   text rows, not another row of boxed stat chips like the grid above:
   these are already emoji-led and sentence-length, so a second row of
   chips would read as a second, competing stats grid rather than a
   quick, skimmable highlight reel sitting underneath the real one. */
.summary-facts-section.hidden { display: none; }
.summary-facts-list { display: flex; flex-direction: column; gap: 6px; }
.summary-fact { display: flex; align-items: center; gap: 8px; font-size: 12.5px; color: var(--text-secondary); line-height: 1.3; }
.summary-fact-icon { flex: 0 0 auto; display: flex; align-items: center; }
/* "Hear how you played" -- a genuine call-to-action (not just another
   informational stat), so it gets the same solid, filled treatment as
   this app's other primary actions (.pill-btn.primary) rather than the
   quieter, text-only look of the mistakes toggle right below it. */
.summary-replay-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  width: 100%;
  height: 38px;
  border-radius: var(--radius-pill);
  border: 1px solid var(--accent-strong);
  background: var(--accent);
  color: var(--accent-contrast);
  font-size: 13.5px;
  font-weight: 600;
  cursor: pointer;
  transition: background .15s ease, box-shadow .15s ease, transform .1s ease;
}
.summary-replay-btn:hover { background: var(--accent-strong); box-shadow: var(--shadow-s); }
.summary-replay-btn:active { transform: scale(0.97); }
/* Save performance, once already saved for this session -- reads as
   plainly inert (no hover/active feedback at all, via :disabled below)
   rather than the normal filled call-to-action look, since clicking it
   again wouldn't do anything further. Still legible, not the same
   dimmed-into-illegibility treatment a disabled destructive action
   might get -- this is a completed, positive state, not a blocked one. */
.summary-replay-btn.saved,
.summary-replay-btn:disabled {
  background: var(--surface-2);
  border-color: var(--border);
  color: var(--text-secondary);
  cursor: default;
}
.summary-replay-btn.saved:hover,
.summary-replay-btn:disabled:hover { background: var(--surface-2); box-shadow: none; }
.summary-replay-btn.hidden { display: none; }
/* Two side-by-side actions once a restart option sits next to "Hear how
   you played" -- each takes half the row instead of the single button's
   own full-width default, with a visible gap between them so they read
   as two distinct choices rather than one long, oddly split button. */
.summary-action-row { display: flex; gap: 8px; }
.summary-action-row .summary-replay-btn { width: auto; flex: 1; }
/* Restart specifically reads as the lighter, secondary action here --
   outline rather than filled -- since it's a reset, not the primary
   "listen to what you just did" call-to-action the filled treatment
   above is reserved for; the destructive weight of the action itself
   already lives in the confirmation dialog it opens, not in how loud
   this button looks sitting next to Replay. */
.summary-restart-btn {
  background: transparent;
  border: 1px solid var(--border);
  color: var(--text-primary);
}
/* Explicitly overrides .summary-replay-btn:hover's own filled
   background: var(--accent-strong) below -- both classes sit on the
   same element (see the HTML), and with equal specificity, that rule
   still applied here even though this button's own :hover only ever
   meant to recolor the border/text, not fill the background too. The
   two together produced accent-toned text sitting on top of a
   similarly accent-toned fill -- reported directly as hard to read on
   hover, confirmed by a real screenshot showing exactly that. Staying
   transparent here on hover, not just at rest, is what actually keeps
   this button reading as the lighter, outline-style secondary action
   the comment above already describes -- a filled hover state was
   never the intent, it just wasn't actually being prevented. */
.summary-restart-btn:hover { background: transparent; border-color: var(--accent); color: var(--accent); }

/* Expandable mistake list -- collapsed by default even when there's
   something to show, so the dialog opens compact and the person opts
   into the detail rather than always being confronted with a long list. */
.summary-mistakes-section { border-top: 1px solid var(--border-soft); padding-top: 12px; }
.summary-mistakes-section.hidden { display: none; }
.summary-mistakes-toggle {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  width: 100%;
  background: none;
  border: none;
  cursor: pointer;
  font-size: 13px;
  font-weight: 600;
  color: var(--text-secondary);
  padding: 4px;
}
.summary-mistakes-toggle:hover { color: var(--text-primary); }
.summary-mistakes-chevron { font-size: 11px; transition: transform .15s ease; display: inline-block; }
.summary-mistakes-toggle.expanded .summary-mistakes-chevron { transform: rotate(180deg); }
.summary-mistakes-list { max-height: 220px; overflow-y: auto; margin-top: 10px; display: flex; flex-direction: column; gap: 4px; }
.summary-mistakes-list.hidden { display: none; }
.summary-mistake-row {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  text-align: left;
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-s);
  padding: 7px 10px;
  cursor: pointer;
  font-size: 12.5px;
  color: var(--text-primary);
  transition: background .12s ease, border-color .12s ease, color .12s ease;
}
.summary-mistake-row:hover { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent-strong); }
/* Reported directly: clicking a mistake jumps the playhead to it, but
   gave no lasting sign afterward of which row that actually was --
   distinct from the hover treatment just above (which is only ever
   transient, gone the moment the pointer moves away), this needs to
   persist until a different mistake is clicked instead. */
.summary-mistake-row.selected {
  border-color: var(--accent-strong);
  background: color-mix(in srgb, var(--accent) 18%, var(--surface-2));
  box-shadow: inset 3px 0 0 var(--accent-strong);
}
.summary-mistake-row.selected:hover { background: var(--accent); color: var(--accent-contrast); }
.summary-mistake-time { font-variant-numeric: tabular-nums; font-weight: 700; flex: 0 0 auto; min-width: 34px; }
.summary-mistake-note { font-weight: 700; flex: 0 0 auto; min-width: 34px; }
.summary-mistake-kind { flex: 1 1 auto; color: var(--text-secondary); font-size: 11.5px; }
.summary-mistake-row:hover .summary-mistake-kind { color: inherit; opacity: 0.85; }
/* A late miss is still a miss, so it keeps the same muted treatment --
   only slightly emphasised, because "you played it, just behind the
   beat" is the more actionable of the two and worth spotting when
   scanning the list. Deliberately not a warning colour: nothing here
   went more wrong than an ordinary miss. */
.summary-mistake-kind.late { font-weight: 600; }
.summary-mistake-hand {
  flex: 0 0 auto;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 10px;
  font-weight: 800;
  color: #fff;
}
.summary-mistake-hand.hand-r { background: var(--hand-r-strong); }
.summary-mistake-hand.hand-l { background: var(--hand-l-strong); }
.summary-mistakes-hint { margin: 8px 0 0; font-size: 11px; color: var(--text-faint); text-align: center; line-height: 1.4; }

.meta-chip.performance-overlay-chip { cursor: pointer; }
.meta-chip.performance-overlay-chip.on { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent-strong); }
.meta-chip.summary-reopen-chip { cursor: pointer; border-style: dashed; }
.meta-chip.summary-reopen-chip:hover { border-color: var(--accent); border-style: solid; color: var(--text-primary); }
/* Colored to match the same right/left hand tint used on the keyboard
   and the practice-range handles, so each chip visually ties back to
   the hand it's actually reporting on, glanceable without reading the
   R/L prefix in the text itself. */
.meta-chip.live-stats-chip-r { border-color: var(--hand-r-strong); color: var(--hand-r-strong); background: color-mix(in srgb, var(--hand-r) 18%, var(--surface-2)); }
.meta-chip.live-stats-chip-l { border-color: var(--hand-l-strong); color: var(--hand-l-strong); background: color-mix(in srgb, var(--hand-l) 18%, var(--surface-2)); }
/* For a track beyond the usual right/left-hand pair -- reuses the same
   generic track-2 tint the falling notes and library table already
   color that track with elsewhere, so a live chip for it still ties
   back to something visually consistent rather than an arbitrary new
   color. */
.meta-chip.live-stats-chip-other { border-color: var(--track-2-strong); color: var(--track-2-strong); background: color-mix(in srgb, var(--track-2) 18%, var(--surface-2)); }

/* Streak calendar day-detail dialog */
.day-detail-title-row { display: flex; align-items: center; gap: 8px; }
.day-detail-level-dot { width: 12px; height: 12px; border-radius: 50%; background: var(--surface-2); flex: 0 0 auto; }
.day-detail-level-dot.streak-level-1 { background: var(--streak-l1); }
.day-detail-level-dot.streak-level-2 { background: var(--streak-l2); }
.day-detail-level-dot.streak-level-3 { background: var(--streak-l3); }
.day-detail-level-dot.streak-level-4 { background: var(--streak-l4); }

.day-detail-badges { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
.day-detail-badges.hidden { display: none; }
.day-detail-badge {
  font-size: 11px;
  font-weight: 700;
  padding: 4px 10px;
  border-radius: var(--radius-chip);
  display: inline-flex;
  align-items: center;
  gap: 4px;
}
/* Both badges share the session summary's own quiet chip treatment
   rather than gold and accent-tinted fills. Reported: the coloured
   pills plus emoji read as gamification stickers on a panel that is
   otherwise a plain record of what happened. The distinction between
   the two is carried by their words, which is enough -- they are
   labels, not a scoreboard. */
.day-detail-badge-best,
.day-detail-badge-streak {
  background: var(--surface-2);
  color: var(--text-secondary);
  border: 1px solid var(--border-soft);
  font-weight: 600;
}

.day-detail-songs-section.hidden { display: none; }
.day-detail-songs-list { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; max-height: 200px; overflow-y: auto; }
.day-detail-song-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-s);
  padding: 7px 10px;
  font-size: 12.5px;
}
.day-detail-song-title { color: var(--text-primary); font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.day-detail-song-time { color: var(--text-secondary); font-variant-numeric: tabular-nums; flex: 0 0 auto; }
/* Play-this-song affordance on a day-detail row. Deliberately the same
   shape and hover treatment as the library's own .lt-play-btn, just a
   size down to suit a denser list -- it does exactly the same thing, so
   it should not look like a different control. */
.day-detail-song-play {
  flex: 0 0 auto;
  width: 22px;
  height: 22px;
  border-radius: var(--radius-knob);
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text-secondary);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  cursor: pointer;
  transition: background .15s ease;
}
.day-detail-song-play svg { margin-left: 1px; }
.day-detail-song-play:hover { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent-strong); }

.day-detail-empty, .day-detail-nodata { margin: 0; font-size: 12px; color: var(--text-faint); text-align: center; line-height: 1.4; }
.day-detail-empty.hidden, .day-detail-nodata.hidden { display: none; }


.more-tracks-btn {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 6px 12px;
  border-radius: var(--radius-s);
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  color: var(--text-primary);
  font-size: 12.5px;
  font-weight: 600;
  cursor: pointer;
  flex: 0 0 auto;
  font-family: var(--font-ui);
}
.more-tracks-btn:hover { background: var(--border-soft); }

.panel {
  position: fixed;
  top: 0;
  right: 0;
  height: 100%;
  width: 360px;
  max-width: 92vw;
  background: var(--surface);
  box-shadow: var(--shadow-l);
  z-index: 100;
  transform: translateX(100%);
  transition: transform .22s cubic-bezier(.4,0,.2,1);
  display: flex;
  flex-direction: column;
}
.panel.show { transform: translateX(0); }

/* Library is a centered modal rather than a right-side slide-in panel --
   wider (to comfortably fit sort/filter controls) and vertically capped
   with its body scrolling internally, unlike the full-height side panels
   this shares its base .panel styling with. */
#libraryPanel {
  top: 50%;
  left: 50%;
  right: auto;
  height: auto;
  max-height: 82vh;
  width: 1360px;
  max-width: 96vw;
  border-radius: var(--radius-l);
  overflow: hidden;
  transform: translate(-50%, -50%) scale(0.96);
  opacity: 0;
  pointer-events: none;
  /* CLOSED MEANS NOT RENDERED, not merely transparent (Oct 2026).
     opacity:0 alone left the closed library fully painted and
     composited, centred ON TOP of the falling-notes canvas -- so every
     frame of playback the browser re-layered every row of it over the
     moving canvas. Its cost grows with the library: measured with 318
     songs (10,341 elements in this panel), Layerize went from 126ms to
     2,548ms per 12s of playback and frames dropped to 30fps.
     visibility:hidden is not painted at all. It flips only AFTER the
     close animation (the 0s step delayed by the transform's .2s) and
     BEFORE the open one (no delay in .show), so the fade and scale look
     exactly as before. Bonus: a hidden panel's buttons are no longer
     reachable with Tab while it is closed. */
  visibility: hidden;
  transition: transform .2s cubic-bezier(.4,0,.2,1), opacity .18s ease, visibility 0s linear .2s;
}
#libraryPanel.show {
  transform: translate(-50%, -50%) scale(1);
  opacity: 1;
  pointer-events: auto;
  visibility: visible;
  transition: transform .2s cubic-bezier(.4,0,.2,1), opacity .18s ease, visibility 0s;
}

/* Same centered-modal treatment as #libraryPanel above (not a full-height
   side panel) -- a note table needs the width, not the height. */
#notesEditorPanel {
  top: 50%;
  left: 50%;
  right: auto;
  height: auto;
  max-height: 82vh;
  width: 900px;
  max-width: 96vw;
  border-radius: var(--radius-l);
  overflow: hidden;
  transform: translate(-50%, -50%) scale(0.96);
  opacity: 0;
  transition: transform .2s cubic-bezier(.4,0,.2,1), opacity .18s ease;
  pointer-events: none;
}
#notesEditorPanel.show {
  transform: translate(-50%, -50%) scale(1);
  opacity: 1;
  pointer-events: auto;
}
.notes-editor-hint { margin: 0 0 12px; font-size: 12.5px; color: var(--text-secondary); line-height: 1.4; }
.notes-editor-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 10px; }
.notes-editor-track-filter {
  font: inherit;
  font-size: 12.5px;
  font-weight: 600;
  color: var(--text-primary);
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  border-radius: 8px;
  padding: 6px 8px;
}
.notes-editor-loop-check { display: inline-flex; align-items: center; gap: 5px; font-size: 12.5px; color: var(--text-secondary); cursor: pointer; user-select: none; }
.notes-editor-count { margin-left: auto; font-size: 12px; color: var(--text-faint); }
.notes-editor-table-wrap { overflow: auto; max-height: 52vh; border: 1px solid var(--border-soft); border-radius: var(--radius-m); }
.notes-editor-table { width: 100%; border-collapse: collapse; font-size: 12.5px; }
.notes-editor-table thead th {
  position: sticky;
  top: 0;
  background: var(--surface-2);
  text-align: left;
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--text-secondary);
  padding: 8px 10px;
  border-bottom: 1px solid var(--border-soft);
  z-index: 1;
}
.notes-editor-table th.net-check-col { width: 30px; }
.notes-editor-table tbody td { padding: 5px 10px; border-bottom: 1px solid var(--border-soft); vertical-align: middle; }
.notes-editor-table tbody tr:hover { background: var(--surface-2); }
.notes-editor-table tbody tr.net-row-invalid { background: color-mix(in srgb, var(--bad) 12%, transparent); }
.notes-editor-table input[type="number"], .notes-editor-table input[type="text"] {
  width: 68px;
  font: inherit;
  font-size: 12.5px;
  color: var(--text-primary);
  background: var(--surface);
  border: 1px solid var(--border-soft);
  border-radius: 6px;
  padding: 3px 6px;
}
.notes-editor-table input[type="text"].net-pitch-input { width: 56px; }
.notes-editor-table select.net-track-select {
  font: inherit;
  font-size: 12.5px;
  color: var(--text-primary);
  background: var(--surface);
  border: 1px solid var(--border-soft);
  border-radius: 6px;
  padding: 3px 4px;
  max-width: 130px;
}
.notes-editor-table .net-remove-btn {
  width: 20px;
  height: 20px;
  border-radius: var(--radius-knob);
  border: none;
  background: transparent;
  color: var(--text-secondary);
  opacity: 0.6;
  cursor: pointer;
  font-size: 11px;
  line-height: 1;
}
.notes-editor-table .net-remove-btn:hover { opacity: 1; background: var(--bad); color: var(--bad-strong); }
.notes-editor-empty, .notes-editor-truncated { margin: 10px 0 0; font-size: 12px; color: var(--text-faint); text-align: center; }
.notes-editor-empty.hidden, .notes-editor-truncated.hidden { display: none; }

/* Streak panel: same centered-modal treatment as the library, narrower
   since a year of heatmap cells needs less room than the song table. */
#streakPanel {
  top: 50%;
  left: 50%;
  right: auto;
  height: auto;
  max-height: 82vh;
  width: 860px;
  max-width: 95vw;
  border-radius: var(--radius-l);
  overflow: hidden;
  transform: translate(-50%, -50%) scale(0.96);
  opacity: 0;
  transition: transform .2s cubic-bezier(.4,0,.2,1), opacity .18s ease;
  pointer-events: none;
}
#streakPanel.show {
  transform: translate(-50%, -50%) scale(1);
  opacity: 1;
  pointer-events: auto;
}

.streak-hero {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 20px 24px;
  border-radius: var(--radius-l);
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  margin-bottom: 18px;
}
.streak-hero-flame { font-size: 46px; line-height: 1; filter: drop-shadow(0 2px 6px rgba(255,120,40,0.35)); flex: 0 0 auto; }
.streak-hero-flame.streak-flame-dim { opacity: 0.32; filter: grayscale(0.6); }
.streak-hero-main { display: flex; flex-direction: column; align-items: flex-start; flex: 0 0 auto; }
/* Colour dropped on request: this was the one orange number left over
   from the flame era, and with the flame gone it read as an alert
   rather than as a stat. Inherits like every other stat value now. */
.streak-hero-number { font-size: 42px; font-weight: 800; line-height: 1; color: var(--text-primary); }
.streak-hero-label { font-size: 12px; color: var(--text-secondary); font-weight: 700; text-transform: uppercase; letter-spacing: .04em; margin-top: 2px; }
.streak-hero-message { flex: 1 1 auto; font-size: 13.5px; color: var(--text-primary); font-weight: 600; text-align: right; line-height: 1.4; }

.streak-stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(118px, 1fr)); gap: 10px; margin-bottom: 22px; }
.streak-stat-card { background: var(--surface-2); border: 1px solid var(--border-soft); border-radius: var(--radius-m); padding: 10px 12px; }
/* The current streak is the one number in this grid that changes
   because of something you did today, and the one people come back to
   look at -- so it gets the accent treatment while the rest stay
   quiet. Requested. Deliberately a tinted fill and a heavier number
   rather than a full accent block: it should lead the eye, not shout
   over the five cards beside it. */
.streak-stat-card-primary {
  /* All blue -- the app's own right-hand note colour. Requested after
     a blue-fill/red-outline version: two note colours on one small
     card read as a warning rather than a highlight, since red means
     "wrong note" everywhere else in this app. One colour, stated
     three ways (fill, border, rule), is the calmer and more legible
     result. Mixed into the surface so the fill stays a tint rather
     than a block, and so it adapts to every theme including custom
     ones. */
  background: color-mix(in srgb, var(--stats-accent) 18%, var(--surface-2));
  border-color: color-mix(in srgb, var(--stats-accent) 60%, var(--border-soft));
  position: relative;
  overflow: hidden;
}
/* A slim accent rule down the leading edge, the same device the
   welcome dialog uses -- one repeated idea rather than a new one. */
.streak-stat-card-primary::before {
  content: '';
  position: absolute;
  left: 0; top: 0; bottom: 0;
  width: 3px;
  background: var(--stats-accent);
}
.streak-stat-card-primary .streak-stat-value {
  font-size: 26px;
  font-weight: 800;
  /* Pulled toward the text colour so it stays legible on the tinted
     fill in every theme -- the raw note-blue on a pale blue wash is
     too low-contrast to read at this size. */
  color: color-mix(in srgb, var(--stats-accent-strong) 78%, var(--text-primary));
}
.streak-stat-card-primary .streak-stat-label {
  color: var(--text-primary);
  font-weight: 600;
}
/* Dimmed to the ordinary card treatment when there is no streak to
   celebrate -- highlighting a zero would be the wrong emphasis. */
.streak-stat-card-primary.streak-stat-card-zero {
  background: var(--surface-2);
  border-color: var(--border-soft);
}
.streak-stat-card-primary.streak-stat-card-zero::before { display: none; }
.streak-stat-card-primary.streak-stat-card-zero .streak-stat-value {
  color: var(--text-primary);
  font-size: 19px;
  font-weight: 700;
}
.streak-stat-card-clickable {
  cursor: pointer;
  transition: background .12s ease, border-color .12s ease;
}
.streak-stat-card-clickable:hover { background: var(--accent); border-color: var(--accent-strong); }
.streak-stat-card-clickable:hover .streak-stat-value,
.streak-stat-card-clickable:hover .streak-stat-label { color: var(--accent-contrast); }
.streak-stat-value { font-size: 19px; font-weight: 700; color: var(--text-primary); line-height: 1.2; }
.streak-stat-label { font-size: 10.5px; color: var(--text-secondary); margin-top: 3px; }

.stats-tab-content { padding-top: 14px; }
.stats-songs-sort-row { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; font-size: 12px; color: var(--text-secondary); }
.stats-songs-sort-row select {
  font-size: 12px; font-family: var(--font-ui); color: var(--text-primary);
  background: var(--surface-2); border: 1px solid var(--border-soft); border-radius: var(--radius-s);
  padding: 4px 6px;
}
.stats-songs-list { display: flex; flex-direction: column; gap: 10px; }
.stats-song-row { background: var(--surface-2); border: 1px solid var(--border-soft); border-radius: var(--radius-m); padding: 10px 12px; }
.stats-song-title { font-size: 13px; font-weight: 700; color: var(--text-primary); margin-bottom: 6px; }
.stats-song-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; }
.stats-song-meta { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 6px; font-size: 11px; color: var(--text-secondary); }
.stats-trend-wrap { display: flex; align-items: center; gap: 8px; margin-top: 8px; }
.stats-trend-svg { width: 100%; max-width: 220px; height: 26px; flex: 1 1 auto; }
.stats-trend-up { color: var(--good-strong); }
.stats-trend-down { color: var(--bad-strong); }
.stats-trend-flat { color: var(--text-secondary); }
.stats-trend-label { font-size: 10.5px; color: var(--text-secondary); white-space: nowrap; flex: 0 0 auto; }
.stats-empty-state { font-size: 12.5px; color: var(--text-secondary); text-align: center; padding: 28px 12px; line-height: 1.5; }
/* The at-a-glance summary shown before the streak hero -- a small,
   bordered block rather than plain text sitting loose at the top of the
   panel, so it reads as its own distinct "here's the short version"
   moment rather than blending into the stat cards below it. */
.stats-digest {
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-m);
  padding: 12px 14px;
  margin-bottom: 14px;
  display: flex;
  flex-direction: column;
  gap: 4px;
}
.stats-digest.hidden { display: none; }
.stats-digest-line { font-size: 12.5px; font-weight: 600; }
/* A genuine cross-reference between two otherwise-separate tabs' data
   (session length vs. accuracy), not a ranking or a trend -- neither
   inherently good nor bad the way the trend line's up/down coloring is,
   so it gets its own quieter, secondary treatment instead of borrowing
   that color language for something it doesn't actually mean here. */
.stats-digest-crossref { color: var(--text-secondary); font-weight: 500; }
.stats-practice-list { display: flex; flex-direction: column; gap: 8px; }
.stats-practice-group-heading {
  font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
  color: var(--text-secondary); margin: 14px 0 2px; padding-top: 4px; border-top: 1px solid var(--border-soft);
}
.stats-practice-group-heading:first-child { margin-top: 0; padding-top: 0; border-top: none; }
.stats-distribution-svg { width: 100%; max-width: 100%; height: 60px; margin-top: 4px; }
/* Matching the calendar above them -- these three charts are one
   picture of the same practice history and should not be two colours. */
.stats-distribution-svg rect { fill: var(--stats-accent); }
.stats-distribution-svg text { fill: var(--text-secondary); font-family: var(--font-ui); }



/* Generic intro paragraph for a panel's own header area (currently just
   Profiles) -- kept as its own small, reusable rule rather than folded
   into a panel-specific selector, the same reasoning as the "hidden-card"
   rule above: a future panel that wants this same treatment just adds
   the class, no new CSS required. */
.panel-intro { font-size: 12.5px; color: var(--text-secondary); margin: 0 0 16px; }

/* Profile switcher pill in the topbar */
.profile-btn-avatar {
  display: inline-flex; align-items: center; justify-content: center;
  width: 18px; height: 18px; border-radius: 50%;
  font-size: 10px; font-weight: 800; color: #1c1c22;
  flex: 0 0 auto;
}

/* Avatar letter controls in the profile dialog: ink swatches beside a
   typeface select, with a live preview underneath so the effect of both
   is visible without saving and reopening. */
.profile-letter-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
/* Narrow on purpose: it holds one character, and a full-width field
   would invite typing a word into it. */
.profile-letter-input { width: 64px; flex: 0 0 auto; text-align: center; font-weight: 700; }
.profile-ink-row { display: flex; gap: 6px; flex: 0 0 auto; }
.profile-ink-swatch {
  width: 28px; height: 28px; border-radius: 8px; cursor: pointer;
  border: 2px solid var(--border); background: var(--surface-2);
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 11px; font-weight: 800; padding: 0;
  color: var(--text-secondary);
}
.profile-ink-swatch.selected { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 30%, transparent); }
.profile-ink-swatch-black { background: #1c1c22; color: #fff; }
.profile-ink-swatch-white { background: #ffffff; color: #1c1c22; }
.profile-avatar-preview-row { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
.profile-avatar-preview-row .sub { font-size: 12px; color: var(--text-secondary); }

/* Profiles panel: the row list under "All profiles" */
.profile-row {
  display: flex; align-items: center; gap: 12px;
  padding: 10px 12px; border-radius: var(--radius-m);
  border: 1px solid var(--border-soft); background: var(--surface-2);
  margin-bottom: 8px;
}
.profile-row.active { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 8%, var(--surface-2)); }
.profile-row-avatar {
  display: flex; align-items: center; justify-content: center;
  width: 34px; height: 34px; border-radius: 50%; flex: 0 0 auto;
  font-size: 14px; font-weight: 800; color: #1c1c22;
}
.profile-row-main { flex: 1 1 auto; min-width: 0; }
.profile-row-name {
  font-size: 14px; font-weight: 700; color: var(--text-primary);
  cursor: pointer; display: inline-block;
  border-bottom: 1px dashed transparent;
}
.profile-row-name:hover { border-bottom-color: var(--text-faint); }
.profile-row-sub { font-size: 11.5px; color: var(--text-secondary); margin-top: 2px; }
.profile-row-actions { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
.profile-switch-btn { padding: 6px 12px; font-size: 12.5px; }
.profile-active-tag {
  font-size: 10.5px; font-weight: 700; color: var(--accent-strong);
  text-transform: uppercase; letter-spacing: .04em; padding: 0 6px;
}
.profile-icon-btn { width: 30px; height: 30px; }
.profile-delete-btn:hover { background: var(--bad); color: var(--bad-strong); border-color: var(--bad-strong); }
.profiles-add-btn { width: 100%; justify-content: center; margin-top: 4px; }

.streak-heatmap-wrap { }
/* Reported: the most recent days looked cropped. The scroller had no
   right-hand padding, so the newest column -- which is the one the view
   scrolls to, and the one carrying the today ring -- sat flush against
   the clipping edge, with its hover scale and today outline both cut
   off. The padding gives those the room they already assumed. */
.streak-heatmap-scroll { overflow-x: auto; padding: 2px 6px 6px 2px; }
/* Reported directly: a brief, attention-drawing pulse for the moment
   the "Days practiced" stat card scrolls this into view -- removed
   again once its own animation finishes (see scrollToStreakHeatmap),
   not left on permanently. */
@keyframes streakHeatmapPulse {
  0%, 100% { box-shadow: 0 0 0 0 transparent; }
  30% { box-shadow: 0 0 0 3px var(--accent); }
}
.streak-heatmap-highlight-pulse { animation: streakHeatmapPulse 1.2s ease; border-radius: var(--radius-m); }
.streak-heatmap-inner { display: inline-flex; flex-direction: column; min-width: 100%; }
.streak-heatmap-months { position: relative; height: 14px; margin-left: 28px; }
.streak-heatmap-month-label { position: absolute; top: 0; font-size: 10px; color: var(--text-secondary); white-space: nowrap; }
.streak-heatmap-body { display: flex; gap: 3px; }
.streak-heatmap-daylabels { display: flex; flex-direction: column; gap: 3px; width: 24px; flex: 0 0 auto; }
.streak-heatmap-daylabel { height: 11px; font-size: 9px; color: var(--text-faint); line-height: 11px; }
.streak-heatmap-grid { display: grid; grid-template-rows: repeat(7, 11px); grid-auto-flow: column; gap: 3px; }
/* ONE definition of the practice-intensity ramp. It was duplicated in
   three places -- the calendar cells, the legend, and the day-detail
   popup's dots -- which is exactly why recolouring the calendar left the
   legend orange: two of the three copies were missed. Anything showing
   an intensity level reads these. */
:root {
  /* One colour for everything that draws practice history: the streak
     card, this calendar ramp, and the two distribution charts.
     Defaults to the note blue, because the built-in themes' own accent
     is a neutral grey and drawing the stats in it made them read as
     shades of nothing. A CUSTOM theme overrides --stats-accent with its
     own picked accent (see deriveCustomTheme), so a pink theme gets
     pink stats instead of blue ones clashing with everything around
     them. */
  --stats-accent: var(--hand-r);
  --stats-accent-strong: var(--hand-r-strong, var(--hand-r));
  --streak-l1: color-mix(in srgb, var(--stats-accent) 26%, var(--surface-2));
  --streak-l2: color-mix(in srgb, var(--stats-accent) 50%, var(--surface-2));
  --streak-l3: color-mix(in srgb, var(--stats-accent) 75%, var(--surface-2));
  --streak-l4: var(--stats-accent-strong);
}
.streak-cell { width: 11px; height: 11px; border-radius: 2.5px; background: var(--surface-2); cursor: pointer; transition: transform .08s ease; }
.streak-cell:hover { transform: scale(1.35); }
/* Recoloured on request. The old ramp was a fire gradient chosen to
   match a flame icon that has since been removed -- with the flame gone
   it read as an alarm state rather than as "you practised". Built from
   the app's own accent instead, so it inherits any custom theme and
   sits with the rest of the UI rather than shouting over it. */
.streak-cell.streak-level-1 { background: var(--streak-l1); }
.streak-cell.streak-level-2 { background: var(--streak-l2); }
.streak-cell.streak-level-3 { background: var(--streak-l3); }
.streak-cell.streak-level-4 { background: var(--streak-l4); }
/* Ring drawn INSIDE the cell: an outer box-shadow was part of what got
   clipped at the scroller's edge on the newest day -- which is exactly
   the day most likely to carry it. */
.streak-cell.streak-today { box-shadow: inset 0 0 0 1.5px var(--text-primary); }
.streak-cell.streak-future { visibility: hidden; cursor: default; }

.streak-heatmap-legend { display: flex; align-items: center; gap: 4px; margin-top: 10px; font-size: 10.5px; color: var(--text-secondary); justify-content: flex-end; }
.streak-legend-cell { width: 10px; height: 10px; border-radius: 2.5px; }
.streak-legend-cell.streak-level-0 { background: var(--surface-2); }
/* Reported still orange after the calendar was recoloured: the legend
   carried its OWN copy of the ramp, so recolouring the cells left it
   behind. Both now derive from the same accent, and -- more to the
   point -- the legend no longer restates the colours at all. It simply
   reuses .streak-cell's own level classes, so the two literally cannot
   disagree again. */
.streak-legend-cell.streak-level-1 { background: var(--streak-l1); }
.streak-legend-cell.streak-level-2 { background: var(--streak-l2); }
.streak-legend-cell.streak-level-3 { background: var(--streak-l3); }
.streak-legend-cell.streak-level-4 { background: var(--streak-l4); }

.panel-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 18px 20px;
  border-bottom: 1px solid var(--border-soft);
  flex: 0 0 auto;
}
.panel-header h3 { margin: 0; font-size: 16px; font-weight: 700; }

.panel-body {
  flex: 1 1 auto;
  overflow-y: auto;
  padding: 16px 20px 28px;
}
.count-chip {
  font-size: 10.5px;
  font-weight: 800;
  background: var(--accent);
  color: var(--accent-contrast);
  border-radius: var(--radius-chip);
  padding: 1px 6px;
  margin-left: 4px;
}
.count-chip.hidden { display: none; }

.panel-close {
  border: 1px solid transparent;
  background: transparent;
  color: var(--text-secondary);
  cursor: pointer;
  width: 30px;
  height: 30px;
  border-radius: 8px;
  font-size: 16px;
  transition: background .15s ease, color .15s ease, box-shadow .15s ease, border-color .15s ease;
}
/* Same red "dismiss" treatment as the card-close and song-delete X
   buttons, so every window/panel-closing X reads consistently rather
   than this one alone hovering to a neutral gray. */
.panel-close:hover { background: var(--bad); color: var(--bad-strong); border-color: var(--bad-strong); box-shadow: var(--shadow-s); }

.dropzone {
  border: 2px dashed var(--border);
  border-radius: var(--radius-m);
  padding: 22px 16px;
  text-align: center;
  color: var(--text-secondary);
  font-size: 13px;
  cursor: pointer;
  transition: border-color .15s ease, background .15s ease;
  margin-bottom: 18px;
}
.dropzone:hover { border-color: var(--accent); background: var(--surface-2); }
.dropzone strong { color: var(--text-primary); display: block; margin-bottom: 4px; font-size: 14px; }

.library-search {
  width: 100%;
  height: 38px;
  padding: 0 14px;
  margin-bottom: 14px;
  border-radius: var(--radius-m);
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text-primary);
  font-size: 13.5px;
  font-family: var(--font-ui);
}
.library-search:focus { outline: none; border-color: var(--accent); }
.library-search::placeholder { color: var(--text-faint); }

.library-empty-msg { font-size: 12.5px; color: var(--text-secondary); padding: 4px 0; }

/* Reported directly: long titles/composers were unreachable -- the panel
   body scrolled vertically, the table never scrolled horizontally, and
   over-long values were simply cut with an ellipsis. The scrolling now
   belongs to the table box itself rather than the whole panel body, for
   one specific reason: a horizontal scrollbar on a wrap that is as tall
   as the full song list sits BELOW the last row, so reaching it means
   scrolling to the bottom of the library first. Bounded here, it is
   pinned to the bottom of the visible table area where it can actually
   be grabbed. Dropzone, search and the section label stay put above it. */
#libraryPanel .panel-body { display: flex; flex-direction: column; overflow: hidden; }
/* The :not() matters. Written as a bare > * this outranks the
   .library-table-wrap rule below on specificity (id + class beats one
   class), so the wrap inherited flex:0 0 auto, grew to the full height
   of the song list and was then clipped by the overflow:hidden above --
   a library that simply stopped mid-row with nothing to scroll. */
#libraryPanel .panel-body > *:not(.library-table-wrap) { flex: 0 0 auto; }
#libraryPanel .library-table-wrap { flex: 1 1 auto; min-height: 0; }
.library-table-wrap { overflow: auto; margin-bottom: 4px; }
/* width AND min-width, and the width half is load-bearing. min-width
   alone let the table grow for long values (its whole purpose) but broke
   column resizing: applyLibraryColWidths pins table-layout to fixed once
   a column has been dragged, and a fixed-layout table with width:auto has
   no defined total to distribute, so an explicit column width barely
   registers -- measured, asking for 400px moved the column three pixels.
   With width:100% the fixed layout resolves properly, and nowrap cells
   still force the table past its container when a title is long, so the
   horizontal scrollbar this was added for still appears. */
.library-table { width: 100%; min-width: 100%; border-collapse: collapse; font-size: 13px; }
.library-table thead th {
  text-align: left;
  padding: 7px 10px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: .03em;
  text-transform: uppercase;
  color: var(--text-secondary);
  border-bottom: 1px solid var(--border);
  cursor: pointer;
  user-select: none;
  white-space: nowrap;
}
.library-table thead th:hover { color: var(--text-primary); }
.library-table thead th.sorted { color: var(--accent-strong); }
/* Reported directly: the play and remove buttons looked cropped. 36px
   was sized for one button and has to hold two, so they were being
   squeezed against the cell edges with no room to breathe. */
/* Reported twice: the play button still looked cropped at 76px. Two
   icon buttons plus their own padding and the cell's right inset need
   more room than a first estimate suggested -- sized now with margin
   rather than to the pixel, since a slightly wide column costs nothing
   and a narrow one crops. */
.library-table thead th.lt-actions { cursor: default; width: 96px; }
.library-table thead th .sort-arrow { display: inline-block; margin-left: 3px; font-size: 9px; }
.library-table tbody td {
  padding: 8px 10px;
  border-bottom: 1px solid var(--border-soft);
  vertical-align: middle;
  color: var(--text-primary);
}
.library-table tbody tr:hover { background: var(--surface-2); }
.library-table tbody tr.active { background: color-mix(in srgb, var(--accent) 12%, transparent); }
.library-table tbody tr.row-selected { background: color-mix(in srgb, var(--accent) 16%, transparent); }
.library-table tbody tr.active.row-selected { background: color-mix(in srgb, var(--accent) 24%, transparent); }
/* Reported three times, and the first two fixes widened the WRONG
   column: the cropped button in the screenshot is the leading play
   button, not the trailing actions cell. The header was pinned at 34px
   while the cell it governs asks for 58px plus 14px of left padding, so
   the column was ~20px narrower than its own contents and clipped the
   button's left edge. Sized to fit the cell with room to spare. */
/* Asked for directly: Play / Hear-your-performance / Remove stay visible.
   They are the first and last columns, so the moment the table scrolls
   sideways (which the change above now makes possible) they were the
   first things to leave the screen -- the two columns you always want
   reachable being the two that vanish first. Pinned to the edges via
   :first-child/:last-child rather than the .lt-play-col/.lt-actions
   classes so the filter row's own untagged th cells pin with them
   instead of sliding out from under the header.

   Sticky cells have to be opaque or the scrolling content shows straight
   through them, so every row state that tints a row is repeated here
   against --surface (color-mix onto transparent would stay see-through).
   The hairline is a plain column divider when nothing is scrolled and
   reads as the seam the content slides under when something is. */
.library-table thead th:first-child,
.library-table thead th:last-child { position: sticky; z-index: 4; background: var(--surface); }
.library-table tbody td:first-child,
.library-table tbody td:last-child { position: sticky; z-index: 2; background: var(--surface); }
.library-table thead th:first-child,
.library-table tbody td:first-child { left: 0; box-shadow: 1px 0 0 var(--border-soft); }
.library-table thead th:last-child,
.library-table tbody td:last-child { right: 0; box-shadow: -1px 0 0 var(--border-soft); }
.library-table tbody tr:hover td:first-child,
.library-table tbody tr:hover td:last-child { background: var(--surface-2); }
.library-table tbody tr.active td:first-child,
.library-table tbody tr.active td:last-child { background: color-mix(in srgb, var(--accent) 12%, var(--surface)); }
.library-table tbody tr.row-selected td:first-child,
.library-table tbody tr.row-selected td:last-child { background: color-mix(in srgb, var(--accent) 16%, var(--surface)); }
.library-table tbody tr.active.row-selected td:first-child,
.library-table tbody tr.active.row-selected td:last-child { background: color-mix(in srgb, var(--accent) 24%, var(--surface)); }
/* The "no songs yet" row is a single colspan cell, so it is both the
   first and the last child -- pinning it would peg a full-width message
   to the left edge while the header scrolled past it. */
.library-table tbody td.library-empty-msg { position: static; background: transparent; box-shadow: none; }
.library-table thead th.lt-play-col { width: 72px; }
.library-table .lt-play-cell { width: 72px; text-align: center; padding-left: 8px; padding-right: 8px; white-space: nowrap; }

.library-filter-row th {
  padding: 4px 6px 8px;
  cursor: default;
  border-bottom: 1px solid var(--border);
}
.library-filter-row th:hover { color: inherit; }
.lt-filter-input, .lt-filter-select {
  width: 100%;
  min-width: 0;
  box-sizing: border-box;
  padding: 4px 6px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-primary);
  font-size: 11.5px;
  font-family: inherit;
  font-weight: 400;
  text-transform: none;
  letter-spacing: normal;
}
.lt-filter-input:focus, .lt-filter-select:focus { outline: none; border-color: var(--accent); }
.lt-filter-input::placeholder { color: var(--text-faint); }
.lt-filter-clear-btn {
  width: 22px;
  height: 22px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-secondary);
  cursor: pointer;
  font-size: 11px;
}
.lt-filter-clear-btn:hover { background: var(--bad); color: var(--bad-strong); border-color: var(--bad-strong); }
.lt-play-btn {
  width: 26px;
  height: 26px;
  border-radius: var(--radius-knob);
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-secondary);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.lt-play-btn svg { margin-left: 1px; }
.lt-play-btn:hover { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent-strong); }
/* Reported directly: a saved performance of this row's own song, shown
   right here instead of as a separate row of its own (see
   performancesForSong). A touch smaller than the Play button beside
   it -- a secondary action sitting next to the primary one, not
   competing with it for attention. */
.lt-performance-btn {
  width: 22px;
  height: 22px;
  margin-left: 4px;
  border-radius: var(--radius-knob);
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-secondary);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
}
.lt-performance-btn:hover { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent-strong); }
/* The 280px/220px caps here were what turned a long title into "Piano
   Sonata No. 14 in C-sharp mi..." with no way to see the rest. Dropped:
   the cell now sizes to its content and the table scrolls sideways
   instead. Hand-resized columns are unaffected -- once
   applyLibraryColWidths pins table-layout to fixed, the generic
   .library-table td clip rule further down still governs them, which is
   the whole point of dragging a column narrower. */
.library-table .lt-title-cell { font-weight: 600; white-space: nowrap; }
.lt-saved-performance-badge { margin-right: 5px; display: inline-flex; align-items: center; color: var(--text-secondary); }
.library-table .lt-text-cell { color: var(--text-secondary); font-size: 12.5px; white-space: nowrap; }
.library-table .lt-editable { cursor: text; border-radius: 6px; }
.library-table .lt-editable:hover { background: var(--surface); box-shadow: inset 0 0 0 1px var(--border); }
.lt-inline-edit-input {
  width: 100%;
  font: inherit;
  color: inherit;
  background: var(--surface);
  border: 1px solid var(--accent);
  border-radius: 5px;
  padding: 2px 5px;
  outline: none;
}
.library-table .lt-empty-dash { color: var(--text-faint); }
.library-table .lt-actions-cell {
  text-align: right;
  white-space: nowrap;
  padding-right: 8px;
}
.library-table .lt-actions-cell button { margin-left: 4px; }
/* Placeholder stats for a song that has never been practised: same
   layout as a real breakdown row, visibly inactive. */
.lt-accuracy-breakdown-empty { opacity: 0.45; }
.song-accuracy-empty { opacity: 0.55; }
.library-table .lt-accuracy-cell { white-space: normal; min-width: 260px; }
.lt-accuracy-breakdown { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px; }
.lt-accuracy-sub {
  display: inline-flex;
  align-items: center;
  font-size: 10.5px;
  font-weight: 600;
  color: var(--text-faint);
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-chip);
  padding: 2px 7px;
  white-space: nowrap;
}

.section-label {
  font-size: 11px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--text-faint);
  margin: 18px 0 8px;
}
.section-label:first-child { margin-top: 0; }

.profile-color-row { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 16px; }
.profile-color-swatch {
  width: 28px;
  height: 28px;
  border-radius: 50%;
  border: 2px solid var(--surface);
  padding: 0;
  cursor: pointer;
  transition: transform .1s ease, box-shadow .1s ease;
}
.profile-color-swatch:hover { transform: scale(1.12); }
.profile-color-swatch.selected { box-shadow: 0 0 0 2px var(--text-primary); }

.song-item {
  position: relative;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 12px;
  border-radius: var(--radius-m);
  cursor: pointer;
  border: 1px solid transparent;
  margin-bottom: 4px;
  transition: background .12s ease;
}
.song-item:hover { background: var(--surface-2); }
.song-item.active { background: var(--surface-2); border-color: var(--border); }
.song-swatch {
  width: 34px;
  height: 34px;
  border-radius: 10px;
  flex: 0 0 auto;
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--text-secondary);
  font-size: 14px;
}
.song-meta { min-width: 0; flex: 1 1 auto; }
.song-meta .t { font-size: 14.5px; font-weight: 600; color: var(--text-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.song-meta-line { font-size: 12px; font-style: italic; color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.song-meta .s { font-size: 12.5px; color: var(--text-secondary); }
.song-chip-row { display: flex; align-items: center; gap: 6px; margin-top: 4px; }
.song-key-btn {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 2px 8px;
  border-radius: var(--radius-chip);
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-secondary);
  font-size: 11.5px;
  font-weight: 600;
  cursor: pointer;
  transition: background .12s ease, color .12s ease, border-color .12s ease;
}
.song-key-btn:hover { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent-strong); }
.song-key-btn.manual { background: color-mix(in srgb, var(--accent) 14%, var(--surface-2)); border-color: var(--accent); color: var(--accent-strong); }
.song-along-chip { cursor: default; }
.song-along-chip:hover { background: var(--surface-2); color: var(--text-secondary); border-color: var(--border); }
.song-accuracy-chip { cursor: default; font-weight: 700; }
.song-accuracy-good { background: color-mix(in srgb, var(--good-strong) 16%, var(--surface-2)); border-color: var(--good-strong); color: var(--good-strong); }
.song-accuracy-good:hover { background: color-mix(in srgb, var(--good-strong) 16%, var(--surface-2)); border-color: var(--good-strong); color: var(--good-strong); }
.song-accuracy-mid { background: color-mix(in srgb, #e0a53f 18%, var(--surface-2)); border-color: #e0a53f; color: #e0a53f; }
.song-accuracy-mid:hover { background: color-mix(in srgb, #e0a53f 18%, var(--surface-2)); border-color: #e0a53f; color: #e0a53f; }
.song-accuracy-low { background: color-mix(in srgb, var(--bad-strong) 14%, var(--surface-2)); border-color: var(--bad-strong); color: var(--bad-strong); }
.song-accuracy-low:hover { background: color-mix(in srgb, var(--bad-strong) 14%, var(--surface-2)); border-color: var(--bad-strong); color: var(--bad-strong); }
.song-difficulty-chip { cursor: default; font-weight: 700; }
.song-difficulty-low { background: color-mix(in srgb, var(--good-strong) 16%, var(--surface-2)); border-color: var(--good-strong); color: var(--good-strong); }
.song-difficulty-low:hover { background: color-mix(in srgb, var(--good-strong) 16%, var(--surface-2)); border-color: var(--good-strong); color: var(--good-strong); }
.song-difficulty-mid { background: color-mix(in srgb, #e0a53f 18%, var(--surface-2)); border-color: #e0a53f; color: #e0a53f; }
.song-difficulty-mid:hover { background: color-mix(in srgb, #e0a53f 18%, var(--surface-2)); border-color: #e0a53f; color: #e0a53f; }
.song-difficulty-high { background: color-mix(in srgb, var(--bad-strong) 14%, var(--surface-2)); border-color: var(--bad-strong); color: var(--bad-strong); }
.song-difficulty-high:hover { background: color-mix(in srgb, var(--bad-strong) 14%, var(--surface-2)); border-color: var(--bad-strong); color: var(--bad-strong); }

.song-key-picker {
  position: fixed;
  z-index: 180;
  background: var(--surface);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-m);
  box-shadow: var(--shadow-l);
  padding: 10px;
  width: 240px;
  max-height: 80vh;
  overflow-y: auto;
}
.song-key-picker-types { display: grid; grid-template-columns: repeat(2, 1fr); gap: 5px; margin-bottom: 8px; }
.song-key-picker-type {
  padding: 5px 0;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-secondary);
  font-size: 12px;
  font-weight: 600;
  cursor: pointer;
}
.song-key-picker-type.selected { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent-strong); }
.song-key-picker-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 5px; }
.song-key-picker-root {
  padding: 6px 0;
  border-radius: 7px;
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-primary);
  font-size: 12.5px;
  font-weight: 600;
  cursor: pointer;
}
.song-key-picker-root:hover { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent-strong); }
.song-key-picker-clear {
  width: 100%;
  margin-top: 8px;
  padding: 6px 0;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text-secondary);
  font-size: 12px;
  cursor: pointer;
}
.song-key-picker-clear:hover { background: var(--surface-2); color: var(--text-primary); }

.song-delete-btn {
  flex: 0 0 auto;
  width: 28px; height: 28px;
  border-radius: 8px;
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text-secondary);
  font-size: 12px;
  cursor: pointer;
}
.song-delete-btn:hover { background: var(--bad); color: var(--bad-strong); border-color: var(--bad-strong); }

/* Social links in Settings, under Feedback. Inline SVG rather than an
   icon font or sprite: three glyphs do not justify a network request in
   an app that already self-hosts everything else. Sized and coloured off
   the same tokens the other settings controls use, so the row reads as
   part of the panel rather than something bolted on. */
.settings-social { display: flex; align-items: center; gap: 8px; }
.settings-social a {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border-radius: 9px;
  color: var(--text-secondary);
  border: 1px solid var(--border);
  background: var(--surface-2);
  transition: color .15s ease, border-color .15s ease, background .15s ease;
}
.settings-social a:hover {
  color: var(--accent-contrast);
  background: var(--accent);
  border-color: var(--accent-strong);
}

.section-label-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin: 18px 0 8px;
}
.section-label-row:first-child { margin-top: 0; }
.section-label-row .section-label { margin: 0; }
.section-label-actions { display: flex; align-items: center; gap: 14px; }

.text-link-btn {
  background: none;
  border: none;
  color: var(--text-secondary);
  font-size: 11px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  cursor: pointer;
  padding: 2px 4px;
}
.text-link-btn:hover { color: var(--text-primary); }
.text-link-btn.active { color: var(--accent-strong); }

.song-select-checkbox {
  flex: 0 0 auto;
  width: 18px;
  height: 18px;
  cursor: pointer;
  accent-color: var(--accent);
}

.bulk-selected-count { font-size: 12.5px; font-weight: 600; color: var(--text-secondary); white-space: nowrap; }
.bulk-delete-btn { padding: 5px 12px; font-size: 12px; }

/* ---------------- Per-track controls (in the row-2 controls line) ---------------- */
.track-controls-empty { font-size: 12.5px; color: var(--text-secondary); white-space: nowrap; }
#trackControlsList {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: nowrap;
  flex: 0 0 auto;
}
.track-ctrl {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 10px;
  border-radius: var(--radius-s);
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  flex: 0 0 auto;
}
/* Reported directly: a follow-up -- the whole row being the click
   target (everywhere except a few specific elements) read as
   ambiguous about which part of it was actually "the button." A
   dedicated options button (see track-options-btn below) now does
   that one job, so this row itself is back to being a plain,
   non-interactive container again -- .track-ctrl-compact stays as a
   class here (still applied in buildCompactTrackRow) even with
   nothing left to style under it specifically. */

.track-color { width: 10px; height: 10px; border-radius: 3px; flex: 0 0 auto; }
.track-name { font-size: 12.5px; font-weight: 600; color: var(--text-primary); white-space: nowrap; }
/* Reported directly: styled like the "+N tracks" button beside it
   (see .more-tracks-btn) -- same idea, "there's more here than what's
   shown," just anchored to one specific track's own row instead of
   the whole set. */
.track-options-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  padding: 0;
  margin-left: 2px;
  border-radius: var(--radius-s);
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  color: var(--text-secondary);
  cursor: pointer;
}
.track-options-btn:hover { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent-strong); }
.track-hand-select {
  font-size: 11px;
  color: var(--text-secondary);
  background: var(--surface);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-s);
  padding: 2px 4px;
  cursor: pointer;
  flex: 0 0 auto;
}
.track-instrument-btn {
  font-size: 11px;
  color: var(--text-secondary);
  background: var(--surface);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-s);
  padding: 2px 6px;
  cursor: pointer;
  flex: 0 0 auto;
  max-width: 110px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.track-instrument-btn:hover { background: var(--surface-2); }
/* Same floating-panel treatment note-edit-reassign-panel already uses
   (see its own CSS comment) -- position set directly in JS
   (openTrackInstrumentPopover) rather than via a fixed spot, since it
   needs to dock under whichever track row's own button was actually
   clicked. */
/* Reported directly: a real bug, not just a style nitpick -- this
   popover is opened from the instrument button INSIDE the Track
   Options dialog (.tracks-dialog, z-index 160), so its own z-index
   needs to clear that specific dialog, not just an ordinary panel.
   Matches .song-key-picker's own z-index (180), the other popover in
   this app anchored to a button sitting inside a dialog rather than
   the plain page. */
.track-instrument-popover {
  position: fixed;
  z-index: 180;
  display: flex;
  flex-direction: column;
  gap: 6px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-m);
  box-shadow: var(--shadow-m);
  padding: 10px 12px;
  font-size: 12px;
  min-width: 200px;
}
.track-instrument-popover.hidden { display: none; }
.track-instrument-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.track-instrument-label { color: var(--text-secondary); white-space: nowrap; }
.track-instrument-row select { flex: 1 1 auto; min-width: 0; }
/* Anchored to a button INSIDE the Library panel (z-index 100, see
   .panel) -- needs to render above that, not behind it, so this can't
   simply reuse track-instrument-popover's own lower z-index. */
.performances-popover {
  position: fixed;
  z-index: 110;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-m);
  box-shadow: var(--shadow-m);
  padding: 6px;
  min-width: 150px;
}
.performances-popover.hidden { display: none; }
.performances-popover-list { display: flex; flex-direction: column; gap: 2px; }
.performances-popover-item {
  text-align: left;
  padding: 7px 10px;
  border-radius: var(--radius-s);
  border: none;
  background: transparent;
  color: var(--text-primary);
  font-size: 12.5px;
  cursor: pointer;
}
.performances-popover-item:hover { background: var(--accent); color: var(--accent-contrast); }
/* Reported directly: a custom replacement for the browser's own
   native <input type="color"> picker, showing only the two controls
   actually wanted -- the gradient square and the hue slider -- with
   no RGB number boxes or anything else alongside them. Same
   shared-popover pattern and z-index as performances-popover just
   above (opened from a button inside the Settings panel, itself
   z-index 100 -- see .panel -- so this needs to sit above that too). */
/* Reported: the picker opened BEHIND the rename-profile dialog. It sat
   at 110 while .confirm-dialog sits at 160, so it could never win --
   which was invisible for its original callers (the theme swatches in
   the Settings panel, which is not a dialog) and only surfaced once the
   same picker was reused from inside one. Placed above .confirm-dialog
   deliberately: this popover is always opened BY something, so it has
   to sit above whatever opened it. */
.color-picker-popover {
  position: fixed;
  z-index: 170;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-m);
  box-shadow: var(--shadow-m);
  padding: 12px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.color-picker-popover.hidden { display: none; }
.color-picker-sv-square {
  position: relative;
  width: 200px;
  height: 140px;
  border-radius: var(--radius-s);
  cursor: crosshair;
  /* The two SV gradients themselves -- background-color (the fully
     saturated hue) is set fresh in JS every time the hue changes (see
     updateColorPickerUI), everything else here is fixed. */
  background-image:
    linear-gradient(to top, #000, transparent),
    linear-gradient(to right, #fff, transparent);
  touch-action: none;
}
.color-picker-sv-handle {
  position: absolute;
  width: 14px;
  height: 14px;
  border-radius: var(--radius-knob);
  background: var(--accent);
  border: 2px solid var(--surface);
  box-shadow: var(--shadow-s);
  transform: translate(-50%, -50%);
  pointer-events: none;
}
.color-picker-hue-slider {
  position: relative;
  width: 200px;
  height: 14px;
  border-radius: var(--radius-thin);
  cursor: pointer;
  background: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00);
  touch-action: none;
}
.color-picker-hue-handle {
  position: absolute;
  top: 50%;
  width: 18px;
  height: 18px;
  border-radius: var(--radius-knob);
  background: var(--accent);
  border: 2px solid var(--surface);
  box-shadow: var(--shadow-s);
  transform: translate(-50%, -50%);
  pointer-events: none;
}
/* Reported directly: the underlying native <select> stays in the DOM,
   fully functional (its own .value/.disabled, every existing 'change'
   listener, dynamic repopulation via innerHTML/appendChild -- all of
   it keeps working completely unmodified) -- just removed from visual
   flow entirely, so custom-select-btn right after it (see
   enhanceSelect) is the only thing actually seen or clicked. */
.custom-select-native {
  position: absolute;
  width: 0;
  height: 0;
  padding: 0;
  border: none;
  opacity: 0;
  overflow: hidden;
  pointer-events: none;
}
.custom-select-btn {
  display: inline-flex;
  align-items: center;
  justify-content: space-between;
  gap: 6px;
  cursor: pointer;
  text-align: left;
  font-family: inherit;
}
.custom-select-btn:disabled { opacity: .5; cursor: default; }
.custom-select-btn-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.custom-select-chevron { flex: 0 0 auto; color: var(--text-secondary); }
/* Anchored to a button that can itself sit inside another popover
   (the soundfont/preset selects live inside trackInstrumentPopover,
   z-index 180) -- needs to clear that, same reasoning as every other
   popover-inside-a-popover here, just above the custom tooltip (200)
   so it never gets buried under that either. */
.custom-select-popover {
  position: fixed;
  z-index: 190;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-m);
  box-shadow: var(--shadow-l);
  padding: 6px;
  min-width: 160px;
  max-width: 320px;
  max-height: 280px;
  overflow-y: auto;
}
.custom-select-popover.hidden { display: none; }
.custom-select-popover-list { display: flex; flex-direction: column; gap: 1px; }
.custom-select-popover-group-label {
  padding: 6px 10px 3px;
  font-size: 10.5px;
  font-weight: 700;
  letter-spacing: .03em;
  text-transform: uppercase;
  color: var(--text-faint);
}
.custom-select-popover-item {
  text-align: left;
  padding: 7px 10px;
  border-radius: var(--radius-s);
  border: none;
  background: transparent;
  color: var(--text-primary);
  font-size: 12.5px;
  cursor: pointer;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.custom-select-popover-item:hover, .custom-select-popover-item.highlighted { background: var(--accent); color: var(--accent-contrast); }
.custom-select-popover-item.selected { font-weight: 700; display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.custom-select-popover-item-check { flex: 0 0 auto; }

.track-ctrl-check {
  display: flex;
  align-items: center;
  gap: 4px;
  font-size: 11px;
  color: var(--text-secondary);
  cursor: pointer;
  white-space: nowrap;
}
.track-ctrl-check input[type="checkbox"] { accent-color: var(--accent); cursor: pointer; }

.setting-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 14px;
}
/* There was no .setting-control rule at all, so a row with more than one
   button just let them flow and wrap at whatever width their text
   happened to be -- REPORTED as the Backup row looking "disorganized or
   misaligned compared to the others". Every other row has exactly one
   control, which is why it only showed up there.

   Right-aligned so the controls line up with the single-control rows
   above and below; that shared right edge is what makes the panel read
   as a column rather than a list of unrelated widgets. */
.setting-control {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  flex-wrap: wrap;
  gap: 8px;
}
/* Opt-IN, on the one row that needs it, rather than a global rule that
   would force a min-width onto every button in Settings and start
   wrapping rows that currently fit. */
.setting-control.stacked-actions { flex-direction: column; align-items: stretch; }
/* An <a> styled as a .pill-btn still gets the browser's underline, which
   made the Privacy and Terms links read as links wearing a button
   rather than as the buttons every other row has. Scoped to anchors, so
   real <button> pills are untouched. */
a.pill-btn { text-decoration: none; }
a.pill-btn:hover { text-decoration: none; }

.setting-control.stacked-actions > .pill-btn {
  width: 100%;
  justify-content: center;
  text-align: center;
  white-space: nowrap;
}
.setting-row .label { font-size: 14px; font-weight: 600; color: var(--text-primary); }
.setting-row .sub { font-size: 12px; color: var(--text-secondary); font-weight: 400; }
.soundfont-library-list { display: flex; flex-direction: column; gap: 6px; margin: -6px 0 14px; }
.soundfont-library-list.hidden { display: none; }
.soundfont-library-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 8px 6px 12px;
  border-radius: var(--radius-s);
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
}
.soundfont-library-name { flex: 1 1 auto; min-width: 0; font-size: 13px; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.soundfont-library-preset-count { font-size: 11px; color: var(--text-faint); white-space: nowrap; }
.soundfont-library-remove-btn {
  width: 22px; height: 22px;
  border-radius: var(--radius-knob);
  border: 1px solid var(--border);
  background: var(--surface);
  color: var(--text-secondary);
  cursor: pointer;
  font-size: 12px;
  flex: 0 0 auto;
}
.soundfont-library-remove-btn:hover { background: var(--bad); color: #fff; border-color: var(--bad-strong); }
.setting-slider {
  display: flex;
  align-items: center;
  gap: 8px;
  width: 140px;
  flex: 0 0 auto;
}
.setting-slider input[type="range"] { flex: 1 1 auto; min-width: 0; }
.setting-slider span {
  font-size: 12px;
  font-weight: 700;
  color: var(--text-secondary);
  font-variant-numeric: tabular-nums;
  width: 26px;
  flex: 0 0 auto;
  text-align: right;
}

/* Wraps rather than squeezing its children: a fourth note-name option
   can push this past the available width, and a second row reads far
   better than four crushed buttons. */
.swatch-row { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.theme-swatch {
  width: 30px; height: 30px;
  border-radius: 9px;
  cursor: pointer;
  border: 2px solid transparent;
}
.theme-swatch.selected { border-color: var(--accent-strong); }

/* Custom theme's own 4 anchor-color pickers -- shown/hidden together
   as a pair with #customThemeRow (see updateCustomThemeSectionVisibility)
   whenever the Custom swatch is (or isn't) the active theme. Each
   <label> wraps its own native <input type="color"> so the whole
   pill -- swatch and text both -- is clickable, not just the small
   color square itself. */
.custom-theme-pickers {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin: -6px 0 4px;
}
.custom-theme-picker {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 12px 6px 6px;
  border-radius: var(--radius-pill);
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  cursor: pointer;
  font-size: 13px;
  color: var(--text-secondary);
}
.custom-color-swatch-btn {
  width: 26px; height: 26px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-knob);
  cursor: pointer;
}

/* The compact "other tracks" row -- reported directly: one small
   swatch per actual non-hand track the loaded song has, in a single
   wrapping row, rather than a full labeled picker (like
   .custom-theme-picker above) repeated once per track. */
.custom-theme-other-tracks-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
  padding: 6px 12px 6px 10px;
  border-radius: var(--radius-pill);
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  font-size: 13px;
  color: var(--text-secondary);
}
.custom-theme-other-tracks-swatches { display: flex; flex-wrap: wrap; gap: 6px; }
.custom-theme-other-track-swatch {
  width: 20px; height: 20px;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-knob);
  cursor: pointer;
}

.lang-btn {
  min-width: 42px;
  height: 30px;
  /* Never wrap the label inside the pill. With four note-name systems
     the row is tight, and in a language whose own labels are wider
     (Japanese, where "音名の表記" plus its subtitle takes more of the
     row) the buttons were squeezed until "C D E" broke onto three
     lines. Wrapping the ROW is fine; wrapping inside a 30px pill is
     not. */
  white-space: nowrap;
  flex: 0 0 auto;
  padding: 0 10px;
  border-radius: 9px;
  border: 2px solid var(--border);
  background: var(--surface);
  color: var(--text-secondary);
  font-size: 12px;
  font-weight: 700;
  cursor: pointer;
  transition: background .15s ease, border-color .15s ease, color .15s ease;
}
.lang-btn:hover { background: var(--surface-2); }
.lang-btn.selected { border-color: var(--accent-strong); color: var(--text-primary); background: var(--surface-2); }

/* Element-agnostic (not "select.styled") so this also applies to the
   custom-select-btn replacing each select's own visible box (see
   enhanceSelect) -- the same closed-state styling either way, a real
   <select> or the button standing in for one. */
.styled {
  border: 1px solid var(--border);
  border-radius: var(--radius-s);
  background: var(--surface);
  padding: 6px 10px;
  font-size: 12.5px;
  color: var(--text-primary);
}

.mini-switch {
  width: 40px;
  height: 24px;
  border-radius: var(--radius-chip);
  background: var(--border);
  position: relative;
  cursor: pointer;
  flex: 0 0 auto;
  transition: background .15s ease;
}
.mini-switch::after {
  content: '';
  position: absolute;
  top: 3px; left: 3px;
  width: 18px; height: 18px;
  border-radius: var(--radius-knob);
  background: #fff;
  box-shadow: var(--shadow-s);
  transition: left .15s ease;
}
.mini-switch.on { background: var(--accent); }
.mini-switch.on::after { left: 19px; }

.hidden { display: none !important; }

/* Generic rule for the "hidden-card" pattern used by every dismissible
   card/panel (practice, key selector, timer, and any future one).
   Deliberately NOT per-ID: a per-ID rule (#someCard.hidden-card) has to be
   remembered and added by hand for every new card, and forgetting it is
   invisible in the JS layer -- the class still toggles correctly, but
   nothing in the stylesheet acts on it, so the card just silently stays
   visible. This happened for real (the practice timer card shipped once
   with exactly this gap). A single generic rule makes that whole category
   of bug structurally impossible instead of relying on remembering it.
   !important is required, not optional: a plain class selector (0,1,0)
   loses to any card's own ID-selector base rule (1,0,0) if that rule sets
   display itself -- #practiceCard does, being flex-column internally.
   Without !important this same bug quietly came back for that one card
   specifically, caught only by checking real computed styles in an actual
   browser (no amount of Node-based mocking computes CSS cascade/
   specificity, so this class of regression is invisible to those tests
   entirely). */
.hidden-card { display: none !important; }

/* The chord/interval analyzer and practice generator specifically get a
   smooth fade+scale on open/close rather than the instant display:none
   every other card above uses -- these two get toggled often enough
   during a practice session that the abrupt appear/disappear was worth
   smoothing out. Deliberately scoped to just these two IDs, not a change
   to the shared rule above: display:none itself can't be transitioned at
   all (it's a discrete, all-or-nothing property change, not an
   animatable one), so the card has to stay in the DOM/layout — hence
   pointer-events:none while hidden, so the now-invisible card can't
   still be clicked or block clicks to whatever's underneath it. Higher
   specificity (ID + class, still !important) is what lets this actually
   win over the generic rule above for exactly these two cards, no more. */
#analyzerCard, #practiceCard { transition: opacity .18s ease, transform .18s var(--ease-standard); }
#analyzerCard.hidden-card {
  display: block !important;
  opacity: 0;
  transform: scale(0.97);
  pointer-events: none;
}
#practiceCard.hidden-card {
  display: flex !important;
  opacity: 0;
  transform: scale(0.97);
  pointer-events: none;
}

/* MIDI status */
#midiStatusDot {
  width: 8px; height: 8px;
  border-radius: 50%;
  background: var(--text-faint);
  flex: 0 0 auto;
}
#midiStatusDot.connected { background: var(--good-strong); box-shadow: 0 0 0 3px rgba(63,184,115,0.2); }

/* Feedback toast */
#feedbackToast {
  position: absolute;
  top: 14px;
  left: 50%;
  transform: translateX(-50%);
  padding: 8px 18px;
  border-radius: var(--radius-chip);
  font-size: 13px;
  font-weight: 700;
  color: #fff;
  background: var(--accent);
  box-shadow: var(--shadow-m);
  opacity: 0;
  pointer-events: none;
  transition: opacity .2s ease, transform .2s ease;
  z-index: 50;
  /* No effect on the existing short messages (already narrower than
     this), but keeps a longer, more detailed one -- e.g. a specific
     troubleshooting message -- wrapping onto a few readable lines
     instead of stretching into one very wide, easy-to-miss line. */
  max-width: min(480px, calc(100vw - 32px));
  white-space: normal;
  text-align: center;
  line-height: 1.4;
}
#feedbackToast.show { opacity: 1; transform: translateX(-50%) translateY(4px); }
#feedbackToast.good { background: var(--good-strong); }
#feedbackToast.bad { background: var(--bad-strong); }

/* ---------------- Chord / Interval Analyzer ---------------- */
#analyzerCard {
  position: absolute;
  top: 14px;
  /* Clear of the canvas zoom slider, which is absolutely positioned at
     right:4px with a 28px width -- so it occupies everything out to
     right:32px. At the previous right:14px this card was drawn over the
     top of it (higher z-index), leaving the "how much time is visible"
     control half-covered and awkward to grab wherever the card
     happened to overlap it. 40px clears the slider's full extent with a
     small gap, rather than sitting flush against it. */
  right: 40px;
  width: 278px;
  max-width: calc(100% - 54px);
  background: var(--surface);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-m);
  box-shadow: var(--shadow-m);
  z-index: 35;
  overflow: hidden;
}

.timer-stepper { position: relative; display: inline-flex; }
/* Scoped to .timer-stepper itself (not .timer-setting) so the same
   stepper look works wherever it's reused -- the Practice Timer card
   originally, the Practice Generator's tempo/timer-goal fields now. */
.timer-stepper input {
  width: 52px;
  padding: 5px 18px 5px 6px;
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-s);
  background: var(--surface-2);
  color: var(--text-primary);
  font-size: 13px;
  text-align: center;
  font-variant-numeric: tabular-nums;
}
/* Hide native number spinners -- replaced with the custom stepper below,
   which stays visually consistent across browsers (native spinners look
   noticeably different in Chrome vs Firefox vs Safari, and don't pick up
   the app's theme colors at all). */
.timer-stepper input::-webkit-outer-spin-button,
.timer-stepper input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.timer-stepper input[type="number"] { -moz-appearance: textfield; appearance: textfield; }
.timer-stepper-arrows {
  position: absolute;
  right: 1px;
  top: 1px;
  bottom: 1px;
  width: 16px;
  display: flex;
  flex-direction: column;
  border-left: 1px solid var(--border-soft);
  border-radius: 0 var(--radius-s) var(--radius-s) 0;
  overflow: hidden;
}
.timer-stepper-btn {
  flex: 1;
  border: none;
  background: var(--surface);
  color: var(--text-secondary);
  font-size: 7px;
  line-height: 1;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
}
.timer-stepper-btn:hover { background: var(--surface-2); color: var(--text-primary); }
.timer-stepper-btn:active { background: var(--accent); color: var(--accent-contrast); }
.timer-stepper-btn:first-child { border-bottom: 1px solid var(--border-soft); }

.analyzer-head {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 10px 10px 10px 12px;
  cursor: grab;
  user-select: none;
  touch-action: none;
}
.analyzer-head:active { cursor: grabbing; }
.analyzer-dot {
  width: 7px; height: 7px;
  border-radius: 50%;
  background: var(--text-faint);
  flex: 0 0 auto;
  transition: background .2s ease, box-shadow .2s ease;
}
.analyzer-dot.active { background: var(--accent-strong); box-shadow: 0 0 0 3px rgba(160,160,168,0.22); }
.analyzer-title {
  flex: 1 1 auto;
  font-size: 11.5px;
  font-weight: 700;
  letter-spacing: .02em;
  color: var(--text-secondary);
  text-transform: uppercase;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.analyzer-toggle {
  flex: 0 0 auto;
  width: 20px; height: 20px;
  border-radius: 6px;
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-secondary);
  font-size: 13px;
  line-height: 1;
  display: flex; align-items: center; justify-content: center;
  cursor: pointer;
}
.analyzer-toggle:hover { color: var(--text-primary); }

.analyzer-body { padding: 0 12px 12px; }
/* Smooth collapse/expand for the analyzer card specifically -- display:
   none (used by every other card sharing this class) can't be
   transitioned at all, so this scopes its own max-height/opacity
   transition to #analyzerCard only, leaving the shared .analyzer-body
   class -- and every other card that reuses it -- on the plain,
   untouched instant-toggle behavior. */
#analyzerCard .analyzer-body {
  max-height: 700px;
  opacity: 1;
  transition: max-height .22s var(--ease-standard), opacity .18s ease;
}
#analyzerCard.collapsed .analyzer-head { padding-bottom: 10px; }
#analyzerCard.collapsed .analyzer-body { display: block; max-height: 0; opacity: 0; overflow: hidden; }

.analyzer-detail-toggle {
  margin: 4px auto 0;
}
#analyzerDetailWrap {
  max-height: 300px;
  opacity: 1;
  overflow: hidden;
  transition: max-height .2s var(--ease-standard), opacity .16s ease;
}
#analyzerDetailWrap.collapsed { max-height: 0; opacity: 0; }

/* ---------------- Practice Generator card (mirrors the analyzer card) ---------------- */
#practiceCard {
  position: absolute;
  top: 14px;
  left: 14px;
  width: 300px;
  max-width: calc(100% - 28px);
  max-height: calc(100% - 28px);
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-m);
  box-shadow: var(--shadow-m);
  z-index: 36;
  overflow: hidden;
}
#practiceCard.collapsed #practiceBody { display: flex; max-height: 0; opacity: 0; overflow: hidden; padding-top: 0; }

#keySelectorCard {
  position: absolute;
  top: 14px;
  left: 326px;
  width: 168px;
  max-width: calc(100% - 340px);
  max-height: calc(100% - 28px);
  background: var(--surface);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-m);
  box-shadow: var(--shadow-m);
  z-index: 34;
  overflow: hidden;
}
#keySelectorCard.collapsed .analyzer-body { display: none; }
#keySelectorCard .analyzer-head { padding-left: 28px; }
.key-selector-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 6px;
  padding: 10px 12px 12px 16px;
}
.key-selector-btn {
  padding: 9px 4px;
  border-radius: var(--radius-s);
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-primary);
  font-size: 12.5px;
  font-weight: 600;
  cursor: pointer;
  transition: background-color .1s ease;
}
.key-selector-btn:hover { background: var(--border-soft); }
.key-selector-btn.on { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent-strong); }
#practiceBody {
  padding: 10px 12px 12px;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 9px;
  max-height: 700px;
  opacity: 1;
  transition: max-height .22s var(--ease-standard), opacity .18s ease;
}
#practiceBody::-webkit-scrollbar { width: 8px; }
#practiceBody::-webkit-scrollbar-thumb { background: var(--border); border-radius: 8px; }
#practiceBody { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }

/* Favorite presets -- same interaction shape as the loop bookmarks row
   (named, renameable, deletable, click to apply), and now the exact
   same one-line-plus-overflow-popover layout too, for the same reason:
   growing into a second line here would shrink the practice card's own
   body against everything below it every time enough favorites piled
   up, the same problem that row was built to avoid in the transport
   bar. See layoutPracticePresetsOverflow / toggPracticePresetsOverflowPopover. */
.practice-presets-row {
  display: flex;
  align-items: center;
  gap: 6px;
  flex-wrap: wrap;
  min-height: 22px;
  position: relative;
  overflow: hidden;
  max-height: 24px;
}
/* display:contents for the same reason as .loop-bookmarks-list: lets
   every chip and "Save favorite" flow as flat siblings of one wrapping
   flex row, so the button tucks in right after the last chip whenever
   there's room, rather than always starting its own line. */
.practice-presets-list { display: contents; }
.practice-preset-chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 2px 4px 2px 10px;
  border-radius: var(--radius-chip);
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-secondary);
  font-size: 11px;
  font-weight: 600;
  cursor: pointer;
  transition: background .12s ease, color .12s ease, border-color .12s ease;
}
.practice-preset-chip:hover { background: var(--accent); color: var(--accent-contrast); border-color: var(--accent-strong); }
.practice-preset-label { cursor: text; max-width: 130px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.practice-preset-label-input {
  width: 100px;
  font: inherit;
  color: var(--text-primary);
  background: var(--surface);
  border: 1px solid var(--accent-strong);
  border-radius: 4px;
  padding: 1px 4px;
  outline: none;
}
.practice-preset-remove-btn {
  width: 15px;
  height: 15px;
  border-radius: var(--radius-thin);
  border: none;
  background: transparent;
  color: inherit;
  opacity: 0.55;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-size: 10px;
  line-height: 1;
  flex: 0 0 auto;
}
.practice-preset-remove-btn:hover { opacity: 1; background: var(--bad); color: var(--bad-strong); }
.practice-preset-add-btn {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 3px 10px 3px 8px;
  border-radius: var(--radius-chip);
  border: 1px dashed var(--border);
  background: transparent;
  color: var(--text-secondary);
  font-size: 11px;
  font-weight: 600;
  cursor: pointer;
  flex: 0 0 auto;
}
.practice-preset-add-btn:hover { border-color: var(--accent); color: var(--text-primary); border-style: solid; }

/* A plain HTML title attribute used to sit on this button, but a native
   tooltip's own positioning is entirely up to the browser/OS -- no CSS
   hook exists to keep it from overflowing a narrow container's own
   edge, which is exactly what happened in a cramped layout (reported
   directly, with a screenshot showing it visibly cut off). This custom
   version fixes that by being real, page-rendered content this app can
   actually measure and reposition. Lives as a direct <body> child (see
   its own placement comment in index.html), not nested inside the
   button itself -- both #practiceCard and #practicePresetsRow clip
   overflowing content, so a tooltip nested inside either one could
   never actually become visible past that boundary no matter what
   z-index it was given. position:fixed and JS-computed coordinates
   (see positionPresetAddTooltip) are what place it correctly relative
   to the button despite that. Not a generic, reusable tooltip system --
   just this one button, which is the one place this was actually
   reported. */
.custom-tooltip {
  position: fixed;
  z-index: 200;
  width: max-content;
  max-width: 220px;
  padding: 6px 10px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-s);
  box-shadow: var(--shadow-m);
  color: var(--text-primary);
  font-size: 11px;
  font-weight: 400;
  line-height: 1.4;
  text-align: left;
  white-space: normal;
  pointer-events: none;
}

/* "+N" overflow tab -- styled like "Save favorite" itself (a dashed
   meta-action, not a favorite), same reasoning as the loop bookmarks
   row's own version of this. */
.practice-presets-overflow-toggle {
  display: inline-flex;
  align-items: center;
  padding: 2px 10px;
  border-radius: var(--radius-chip);
  border: 1px dashed var(--border);
  background: transparent;
  color: var(--text-secondary);
  font-size: 11px;
  font-weight: 700;
  cursor: pointer;
  flex: 0 0 auto;
}
.practice-presets-overflow-toggle:hover, .practice-presets-overflow-toggle.open { border-color: var(--accent); border-style: solid; background: var(--surface-2); color: var(--text-primary); }

/* Floating rather than an inline expansion, same reasoning as the loop
   bookmarks popover: never grow the row just to reveal the rest of the
   favorites. position:fixed with coordinates set by JS (see
   positionPracticePresetsOverflowPopover). */
.practice-presets-overflow-popover {
  position: fixed;
  z-index: 90;
  min-width: 200px;
  max-width: 320px;
  max-height: 220px;
  overflow-y: auto;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-m);
  box-shadow: var(--shadow-m);
  padding: 8px;
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
.practice-presets-overflow-popover.hidden { display: none; }

.practice-tabs { display: flex; gap: 4px; }
.practice-tab {
  flex: 1 1 0;
  padding: 6px 4px;
  font-size: 12px;
  font-weight: 700;
  font-family: var(--font-ui);
  color: var(--text-secondary);
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-s);
  cursor: pointer;
}
.practice-tab.selected { background: var(--accent); border-color: var(--accent-strong); color: var(--accent-contrast); }

.practice-opt-col { display: flex; flex-direction: column; gap: 4px; }
.practice-opt-col > label { font-size: 12px; font-weight: 600; color: var(--text-secondary); }
.practice-checks { display: flex; flex-wrap: wrap; gap: 4px; }
.p-check {
  font-size: 11px;
  font-weight: 700;
  font-family: var(--font-ui);
  color: var(--text-secondary);
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-chip);
  padding: 3px 9px;
  cursor: pointer;
}
.p-check.on { background: var(--accent); border-color: var(--accent-strong); color: var(--accent-contrast); }
.p-check.disabled { opacity: 0.35; cursor: not-allowed; pointer-events: none; }

.practice-bpm { display: flex; align-items: center; gap: 7px; flex: 0 0 auto; }
.practice-bpm .timer-stepper { flex: 0 0 auto; }
.practice-bpm .timer-stepper input { width: 44px; font-size: 12px; padding: 4px 16px 4px 4px; }
.practice-bpm span[data-i18n="pBpmUnit"] { font-size: 12px; color: var(--text-secondary); }

.practice-seq-head { display: flex; align-items: center; gap: 6px; }
.practice-mini-btn {
  font-size: 10.5px;
  font-weight: 700;
  font-family: var(--font-ui);
  color: var(--text-secondary);
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-chip);
  padding: 3px 9px;
  cursor: pointer;
}
.practice-mini-btn:hover { color: var(--text-primary); }

.custom-builder-row { display: flex; gap: 6px; align-items: center; }
.custom-builder-row select { flex: 1 1 0; min-width: 0; font-size: 12px; padding: 5px 6px; }
.custom-builder-row .practice-mini-btn { flex: 0 0 auto; }

.practice-seq {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px;
  min-height: 24px;
  align-content: flex-start;
}
.practice-seq-empty { font-size: 11.5px; color: var(--text-faint); }
.practice-chip {
  display: flex;
  align-items: center;
  gap: 4px;
  font-size: 11.5px;
  font-weight: 700;
  font-family: var(--font-ui);
  color: var(--accent-contrast);
  background: var(--accent);
  border: 1px solid var(--accent-strong);
  border-radius: var(--radius-chip);
  padding: 3px 4px 3px 10px;
  cursor: grab;
  transition: transform .12s ease;
}
.practice-chip .chip-remove { display: flex; align-items: center; opacity: 0.85; cursor: pointer; }
.practice-chip .chip-remove:hover { opacity: 1; }
.practice-chip.dragging { opacity: 0.97; box-shadow: var(--shadow-l); transform: scale(1.06); cursor: grabbing; }
.practice-chip.drop-target { outline: 2px solid var(--good-strong); outline-offset: 1px; }
.practice-seq-arrow { color: var(--text-faint); font-size: 12px; }

.practice-options { display: flex; flex-direction: column; gap: 6px; }
.practice-opt-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.practice-opt-row label { font-size: 12px; font-weight: 600; color: var(--text-secondary); flex: 0 0 auto; }
.practice-opt-row select { flex: 0 1 150px; min-width: 0; font-size: 12px; padding: 5px 8px; }
.practice-opt-row.hidden-row, .practice-opt-col.hidden-row { display: none; }

.practice-generate-btn { justify-content: center; width: 100%; }

.analyzer-stage {
  position: relative;
  border-radius: var(--radius-s);
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  padding: 12px 10px 10px;
  text-align: center;
  overflow: hidden;
}
.analyzer-ring {
  position: absolute;
  left: 50%; top: 40%;
  width: 90px; height: 90px;
  margin: -45px 0 0 -45px;
  border-radius: 50%;
  border: 1.5px solid var(--accent);
  opacity: 0;
  pointer-events: none;
}
.analyzer-ring.pulse { animation: analyzerRingPulse .8s ease-out; }
@keyframes analyzerRingPulse {
  0% { opacity: .5; transform: scale(.55); }
  100% { opacity: 0; transform: scale(1.3); }
}
.analyzer-main {
  position: relative;
  font-family: ui-serif, Georgia, 'Times New Roman', serif;
  font-size: 23px;
  font-weight: 700;
  color: var(--text-primary);
  line-height: 1.2;
  min-height: 24px;
}
.analyzer-sub {
  position: relative;
  margin-top: 3px;
  font-size: 12.5px;
  color: var(--text-secondary);
  min-height: 14px;
}
.analyzer-details {
  margin-top: 9px;
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius-s);
  padding: 9px 11px;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 12px;
  line-height: 1.85;
  color: var(--text-secondary);
}
.analyzer-row { display: flex; justify-content: space-between; gap: 8px; }
.analyzer-row span:first-child { color: var(--text-faint); flex: 0 0 auto; }
.analyzer-row b {
  color: var(--text-primary);
  font-weight: 700;
  text-align: right;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 172px;
}
.analyzer-row-intervals { flex-direction: column; align-items: flex-start; gap: 2px; }
.analyzer-row-intervals b {
  font-size: 11px;
  font-weight: 600;
  line-height: 1.5;
  text-align: left;
  white-space: normal;
  max-width: none;
  min-height: 33px;
  max-height: 50px;
  overflow-y: auto;
  width: 100%;
}
#analyzerRowQuality.quality-perfect { color: var(--good-strong); }
#analyzerRowQuality.quality-imperfect { color: var(--good-strong); opacity: 0.85; }
#analyzerRowQuality.quality-consonant { color: var(--good-strong); }
#analyzerRowQuality.quality-dissonant { color: var(--bad-strong); }

.analyzer-foot {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  margin-top: 9px;
  font-size: 11.5px;
  color: var(--text-secondary);
}

/* Fullscreen tweak */
html:fullscreen #topbar { box-shadow: none; }

/* Scrollbars */
.panel-body, .analyzer-row-intervals b { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
.panel-body::-webkit-scrollbar, .analyzer-row-intervals b::-webkit-scrollbar { width: 8px; }
.panel-body::-webkit-scrollbar-thumb, .analyzer-row-intervals b::-webkit-scrollbar-thumb { background: var(--border); border-radius: 8px; }

/* Responsive */
@media (max-width: 760px) {
  :root { --keyboard-h: 108px; }
  #transport { padding: 8px 12px 10px; gap: 6px; }
  .divider { display: none; }
  #songTitle { display: none; }
  /* Hidden with the song title: the topbar already scrolls at this
     width, and the same link is in Settings -> Socials. */
  .kofi-topbar { display: none; }
  #analyzerCard { width: 176px; top: 8px; right: 8px; }
  .analyzer-title { display: none; }
  .analyzer-main { font-size: 15px; }

  /* The topbar holds 8 buttons plus the brand mark, which silently
     overflowed the viewport on narrow screens with no way to reach the
     clipped ones at all (confirmed directly: Settings, Fullscreen, and
     three others were completely unreachable, not just scrolled off —
     there was no scroll affordance in the first place). Horizontal
     scroll is the lowest-risk fix here: it needs no new component (no
     hamburger menu / dropdown to build, wire up, and test), it's a
     familiar mobile pattern, and critically it guarantees every button
     stays reachable rather than requiring a judgment call about which
     ones to hide behind a "more" menu. */
  #topbar {
    overflow-x: auto;
    overflow-y: hidden;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none; /* the fade hint below communicates scrollability instead of a visible scrollbar */
  }
  #topbar::-webkit-scrollbar { display: none; }
  #topbar .pill-btn, #topbar .icon-btn, .brand { flex: 0 0 auto; }
  /* Without this, the spacer's flex:1 grows to fill all available space
     before the scrollable overflow ever kicks in, pushing the action
     buttons far to the right of the initial view — the user would have
     to scroll through a stretch of empty space before reaching them. */
  .topbar-spacer { flex: 0 0 12px; min-width: 12px; }
  #topbar .pill-btn { padding: 0 11px; font-size: 13px; }

  /* Right-edge fade: a plain, hard-clipped overflow looks identical to
     the original bug (content just stops) unless there's some visual
     signal that more is reachable by scrolling. A simple always-on
     gradient is enough here — the topbar's content width varies by
     locale/translation (button labels differ in length across
     languages), so a JS-driven "only show near the true end" version
     would need to recompute on every resize and language switch for
     marginal benefit over just always hinting that this bar scrolls. */
  #topbar { position: relative; }
  #topbar::after {
    content: '';
    position: absolute;
    top: 0; right: 0; bottom: 0;
    width: 28px;
    background: linear-gradient(to right, transparent, var(--surface));
    pointer-events: none;
  }
}


/* The whole custom-theme picker row is clickable (opens the color picker), not just the swatch box -- reported directly. */
.custom-theme-picker { cursor: pointer; }

/* Resizable library columns -- reported directly. A thin grab-strip on
   each header's right edge; the header itself stays a sort button, so
   the strip is deliberately narrow and sits above it in z-order. */
.library-table thead tr:first-child th { position: relative; }
.lt-col-resizer {
  position: absolute;
  top: 0;
  right: 0;
  width: 7px;
  height: 100%;
  cursor: col-resize;
  user-select: none;
  touch-action: none;
  z-index: 2;
}
.lt-col-resizer:hover { background: var(--accent); opacity: .55; }
/* While a drag is in progress the col-resize cursor should hold
   everywhere, not just over the 7px strip the pointer may have left. */
body.lt-col-resizing, body.lt-col-resizing * { cursor: col-resize !important; }
body.lt-col-resizing { user-select: none; }
/* Once columns are pinned to explicit widths, long values need to clip
   rather than force the column back open -- that clipping IS the point
   of narrowing a column. */
.library-table { table-layout: auto; }
.library-table td { overflow: hidden; text-overflow: ellipsis; }

/* Delete affordance on a Notes Editor track chip. Sits inside the chip
   rather than beside it so the row keeps one item per track at any
   width, and inherits the chip's own contrast-picked text color so it
   stays legible on every track color the theme can produce. Hidden
   entirely when only one track remains -- a control that is always
   refused is worse than no control. */
.note-edit-reassign-track-btn { position: relative; }
.note-edit-track-delete {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.05em;
  height: 1.05em;
  margin-left: 0.45em;
  /* Deliberately not a circle: this app reserves fully-round shapes for
     status dots, avatars and swatches -- controls use the chip radius.
     See test_squared_shapes.js, which pins that rule down. */
  border-radius: var(--radius-chip);
  font-size: 0.95em;
  line-height: 1;
  opacity: 0.55;
  cursor: pointer;
  background: rgba(0, 0, 0, 0.16);
  color: inherit;
  transition: opacity 0.12s ease, background 0.12s ease;
}
.note-edit-track-delete::after { content: '\00d7'; }
.note-edit-track-delete:hover {
  opacity: 1;
  background: rgba(0, 0, 0, 0.34);
}

/* Key / time-signature chips on the transport bar. Buttons rather than
   plain .meta-chip spans, since these two are clickable where the BPM
   chip beside them is not -- the hover affordance is what tells them
   apart. .manual mirrors the library chip's own state class so a
   hand-set value reads the same in both places. */
.meta-chip-btn {
  cursor: pointer;
  border: 1px solid transparent;
  font: inherit;
  transition: border-color 0.12s ease, background 0.12s ease;
}
.meta-chip-btn:hover { border-color: var(--accent); }
.meta-chip-btn.manual { border-color: var(--accent); font-style: italic; }
.song-timesig-custom-input { z-index: 60; width: 120px; }

/* Record button: the dot turns red and pulses while a take is running,
   so "am I recording right now" is answerable at a glance from across
   the room rather than by remembering which button was pressed last. */
#noteEditRecordBtn.recording { color: #d64545; }
#noteEditRecordBtn.recording svg { animation: record-pulse 1.1s ease-in-out infinite; }
@keyframes record-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.35; } }

/* New-song dialog fields. Stacked label-above-input rather than inline,
   so the two numeric inputs line up with each other at any width. */
.new-song-field {
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin: 10px 0;
  text-align: left;
  font-size: 0.9em;
}
.new-song-field input {
  padding: 6px 8px;
  border-radius: var(--radius-chip);
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text-primary);
  font: inherit;
}

/* Credits list in settings. Small, quiet type -- this is a licence
   obligation and an honest disclosure, not a feature to advertise, so
   it should be findable without competing with the actual controls. */
.credits-row { display: block; }
.credits-list { display: flex; flex-direction: column; gap: 6px; }
.credits-item {
  font-size: 0.8em;
  line-height: 1.45;
  color: var(--text-secondary, var(--text-primary));
}
.credits-item a { color: var(--accent); text-decoration: none; word-break: break-all; }
.credits-item a:hover { text-decoration: underline; }

/* Storage usage bar. Deliberately plain until it matters -- it turns
   red only at the same threshold that triggers the warning toast, so
   the two can't tell the person different things. */
.storage-bar {
  width: 90px;
  height: 6px;
  border-radius: var(--radius-chip);
  background: var(--surface-2, rgba(0, 0, 0, 0.12));
  overflow: hidden;
  flex-shrink: 0;
}
.storage-bar-fill {
  height: 100%;
  width: 0;
  background: var(--accent);
  transition: width 0.2s ease, background 0.2s ease;
}
.storage-bar-fill.near-full { background: #d64545; }


/* Recording indicator over the falling-notes canvas. Reported: it was
   not obvious enough that a take was running -- the toolbar button alone
   is small and sits outside where the eyes actually are while playing.
   Centred at the top of the canvas, which is the one place already being
   watched. Pointer-events none so it can never swallow a click meant for
   a note underneath it. */
.recording-banner {
  position: absolute;
  top: 10px;
  left: 50%;
  transform: translateX(-50%);
  display: none;
  align-items: center;
  gap: 7px;
  padding: 5px 12px;
  border-radius: var(--radius-pill);
  background: rgba(214, 69, 69, 0.92);
  color: #fff;
  font-size: 0.82em;
  font-weight: 600;
  letter-spacing: 0.02em;
  pointer-events: none;
  z-index: 12;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
}
.recording-banner.show { display: flex; }
.recording-banner-dot {
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #fff;
  animation: recording-banner-pulse 1.1s ease-in-out infinite;
}
@keyframes recording-banner-pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.25; } }




/* Scale dots: a small mark near each key's base showing the notes of
   the song's key. A separate visual channel from key COLOUR on purpose
   -- colour already carries hand/track identity, green/red hit
   feedback, and the custom theme, and a fourth meaning on that same
   channel would collide worst exactly when the feedback matters. */
.wkey.scale-dot::before,
.bkey.scale-dot::before {
  content: '';
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  border-radius: 50%;
  pointer-events: none;
  z-index: 2;
}
.wkey.scale-dot::before {
  bottom: 7px;
  width: 5px;
  height: 5px;
  background: var(--accent);
  opacity: 0.55;
}
.bkey.scale-dot::before {
  bottom: 5px;
  width: 4px;
  height: 4px;
  background: var(--accent-contrast, #fff);
  opacity: 0.5;
}

/* ============================================================
   Practice stats inside the Profiles panel
   ============================================================
   Reported: the stats read as a second, unrelated feature pasted below
   the profile — two card grids, two headings, and three numbers shown
   twice. The duplication is fixed in the markup and renderer; this is
   the visual half, pulling the section into the panel's own rhythm
   instead of leaving it looking like a transplanted panel.

   The Overview tab's own content used to sit inside a dedicated panel
   with its own generous padding. Dropped into a section of a shared
   panel it inherited none of that, which is what made it look like it
   had fallen in from somewhere else. */
#profilesPanel .practice-tabs {
  margin-bottom: 12px;
}
/* The flame block was sized as the hero of a whole panel. Here it is
   one element inside a section, so it is scaled to sit with the cards
   below it rather than tower over them. */
#profilesPanel .streak-hero {
  padding: 14px 18px;
  gap: 12px;
  margin-bottom: 12px;
}
/* Day streak sized like every other stat card (was 30px, an outlier
   that read as a separate feature). */
/* Day streak now renders as an ordinary grid card (see the cards array
   in renderStreakStatsGrid); the full-width hero block it used to have
   is hidden so the stat reads as one of the set, not a banner above
   them. */
#profilesPanel .streak-hero { display: none; }

/* Cards keyed to the panel's own surface so the grid reads as part of
   the section rather than a floating block. */
#profilesPanel .streak-stats-grid { margin-bottom: 14px; }
/* The heatmap scrolls horizontally; inside a narrower panel it needs to
   not push the whole section wider than its neighbours. */
#profilesPanel .streak-heatmap-wrap { margin-bottom: 4px; }
#profilesPanel .stats-tab-content { padding-top: 2px; }
/* Activity tab headings: same weight as the panel's own section labels,
   so the two kinds of heading in this panel do not compete. */
#profilesPanel .stats-practice-group-heading {
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-secondary);
  margin: 14px 0 8px;
}


/* The flame emoji was removed on request -- an emoji as a hero graphic
   reads as a generated-app tell. What is left is the number itself,
   which was always the actual information; the warm tinted panel behind
   it is dropped too, since it existed to frame the flame. */
#profilesPanel .streak-hero {
  background: var(--surface-2);
  border: 1px solid var(--border-soft);
}
#profilesPanel .streak-hero.streak-hero-zero { opacity: 0.6; }


/* Tab switching must not resize or scroll the panel.
   Reported: switching Overview <-> Activity moved and widened things.
   I could not reproduce it in the headless harness -- both tabs measure
   identically there -- so these target the two mechanisms that produce
   exactly that symptom in a real browser, rather than a guess at the
   markup:

   1. WIDENING. When one tab's content is tall enough to need a vertical
      scrollbar and the other's is not, the scrollbar appears and
      disappears on switch, and on platforms with classic (non-overlay)
      scrollbars that changes the content width by ~15px -- everything
      inside visibly reflows. Reserving the gutter permanently means the
      width never depends on which tab is showing.

   2. MOVING. A shorter tab cannot hold the current scroll position, so
      the browser clamps scrollTop and the panel jumps. A minimum height
      on the tab content keeps the shorter tab from collapsing far enough
      to force that. */
#profilesPanel .panel-body { scrollbar-gutter: stable; }
#profilesPanel .stats-tab-content { min-height: 420px; }
/* The heatmap is the widest thing in here and scrolls on its own axis;
   without this it can stretch the panel's content box instead, which
   would reintroduce the horizontal shift from a different direction. */
#profilesPanel .streak-heatmap-wrap { max-width: 100%; overflow: hidden; }

/* =========================================================
   Orbiting two-keys mark (idle screen + upload loader).
   =========================================================
   The approved motion: an edge-on binary orbit. Each key sweeps
   horizontally past the other, growing and moving in front on the near
   side of the orbit, shrinking behind on the far side, swapping
   z-index exactly at the crossings. Both keys hold the mark's fixed
   inclination the whole time -- the tilt is posture, not motion.
   ease-in-out makes them accelerate through the middle the way real
   orbiting bodies do. The small loader variant runs the same orbit
   faster and tighter. prefers-reduced-motion freezes the pair in the
   crossing pose rather than hiding anything. */
.orbit-mark { position: relative; width: 64px; height: 56px; flex: none; }
.orbit-key {
  position: absolute; left: 50%; top: 50%;
  width: 24px; height: 38px; margin: -19px 0 0 -12px;
  border-radius: 6px;
  display: flex; align-items: center; justify-content: center;
  font-family: var(--font-ui); font-weight: 500; font-size: 15px;
}
.orbit-key-w { background: #ffffff; border: 1.5px solid #000000; color: #000000; animation: orbit-near 2.6s ease-in-out infinite; }
.orbit-key-b { background: #000000; border: 1.5px solid #ffffff; color: #ffffff; animation: orbit-far 2.6s ease-in-out infinite; }
@keyframes orbit-near {
  0%   { transform: rotate(-16deg) translateX(-13px) translateY(3px) scale(1.08); z-index: 2; }
  49%  { z-index: 2; }
  50%  { transform: rotate(-16deg) translateX(13px) translateY(-3px) scale(0.9); z-index: 1; }
  99%  { z-index: 1; }
  100% { transform: rotate(-16deg) translateX(-13px) translateY(3px) scale(1.08); z-index: 2; }
}
@keyframes orbit-far {
  0%   { transform: rotate(-16deg) translateX(13px) translateY(-3px) scale(0.9); z-index: 1; }
  49%  { z-index: 1; }
  50%  { transform: rotate(-16deg) translateX(-13px) translateY(3px) scale(1.08); z-index: 2; }
  99%  { z-index: 2; }
  100% { transform: rotate(-16deg) translateX(13px) translateY(-3px) scale(0.9); z-index: 1; }
}
.orbit-mark-small { width: 22px; height: 20px; }
.orbit-mark-small .orbit-key {
  width: 9px; height: 14px; margin: -7px 0 0 -4.5px;
  border-width: 1px; border-radius: 2.5px; font-size: 6px;
  animation-duration: 1.6s;
}
.orbit-mark-small .orbit-key-w { animation-name: orbit-near-sm; }
.orbit-mark-small .orbit-key-b { animation-name: orbit-far-sm; }
@keyframes orbit-near-sm {
  0%   { transform: rotate(-16deg) translateX(-5px) translateY(1px) scale(1.08); z-index: 2; }
  49%  { z-index: 2; }
  50%  { transform: rotate(-16deg) translateX(5px) translateY(-1px) scale(0.9); z-index: 1; }
  99%  { z-index: 1; }
  100% { transform: rotate(-16deg) translateX(-5px) translateY(1px) scale(1.08); z-index: 2; }
}
@keyframes orbit-far-sm {
  0%   { transform: rotate(-16deg) translateX(5px) translateY(-1px) scale(0.9); z-index: 1; }
  49%  { z-index: 1; }
  50%  { transform: rotate(-16deg) translateX(-5px) translateY(1px) scale(1.08); z-index: 2; }
  99%  { z-index: 2; }
  100% { transform: rotate(-16deg) translateX(5px) translateY(-1px) scale(0.9); z-index: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .orbit-key-w, .orbit-key-b { animation: none; }
  .orbit-key-w { transform: rotate(-16deg) translateX(-13px) translateY(3px) scale(1.08); z-index: 2; }
  .orbit-key-b { transform: rotate(-16deg) translateX(13px) translateY(-3px) scale(0.9); z-index: 1; }
  .orbit-mark-small .orbit-key-w { transform: rotate(-16deg) translateX(-5px) translateY(1px) scale(1.08); }
  .orbit-mark-small .orbit-key-b { transform: rotate(-16deg) translateX(5px) translateY(-1px) scale(0.9); }
}

/* Total-time breakdown dialog rows: label, proportional bar, value.
   Playing gets its own accent so it reads as a different measurement
   from the panel timers rather than another slice of the same pie. */
.time-breakdown-row { display: grid; grid-template-columns: 130px 1fr 56px; align-items: center; gap: 10px; margin-bottom: 10px; }
.time-breakdown-label { font-size: 13px; color: var(--text-secondary); }
.time-breakdown-bar { height: 8px; border-radius: 4px; background: var(--surface-2, rgba(128,128,128,0.15)); overflow: hidden; }
.time-breakdown-fill { height: 100%; border-radius: 4px; background: var(--text-secondary); min-width: 2px; }
.time-breakdown-fill-playing { background: var(--accent); }
.time-breakdown-value { font-size: 13px; font-weight: 600; color: var(--text-primary); text-align: right; }

/* Year selector above the streak heatmap. Hidden until there is history
   older than the rolling window, so a new user never sees a control
   that would do nothing. */
.streak-year-row { display: flex; align-items: center; justify-content: center; gap: 10px; margin: 0 0 8px; }
.streak-year-row.hidden { display: none; }
.streak-year-btn {
  width: 24px; height: 24px; padding: 0; line-height: 1;
  border: 1px solid var(--border-soft); border-radius: var(--radius-s);
  background: var(--surface); color: var(--text-secondary); cursor: pointer; font-size: 14px;
}
.streak-year-btn:disabled { opacity: 0.35; cursor: default; }
.streak-year-label { font-size: 12px; font-weight: 600; color: var(--text-secondary); min-width: 104px; text-align: center; }

/* Account row controls wrap instead of overflowing the panel: the long
   "Manage subscription" label was cropping at the panel edge on
   narrower screens. */
#accountRow .setting-control { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; }
#accountRow .label { font-size: 13px; word-break: break-all; }

/* First-run walkthrough. The spotlight is a single div with an enormous
   box-shadow: everything outside it is dimmed, the element inside stays
   fully lit and clickable-looking, and there is no second canvas to
   keep in sync with the real layout. */
#tourOverlay { position: fixed; inset: 0; z-index: 300; pointer-events: none; }
#tourOverlay.hidden { display: none; }
#tourSpotlight {
  position: absolute; border-radius: 10px;
  box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55);
  transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
  pointer-events: none;
}
#tourBubble {
  position: absolute; max-width: 320px; pointer-events: auto;
  background: var(--surface); color: var(--text-primary);
  border: 1px solid var(--border); border-radius: var(--radius-m, 12px);
  padding: 16px 18px; box-shadow: 0 12px 32px rgba(0, 0, 0, 0.22);
  transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
#tourText { font-size: 14px; line-height: 1.55; }
#tourFooter { display: flex; align-items: center; justify-content: space-between; margin-top: 14px; gap: 12px; }
#tourProgress { font-size: 12px; color: var(--text-secondary); }
.tour-actions { display: flex; gap: 8px; align-items: center; }
.tour-skip { background: none; border: 0; color: var(--text-secondary); font-size: 13px; cursor: pointer; padding: 6px 8px; }
.tour-skip:hover { color: var(--text-primary); }

/* Welcome dialog: the same confirm dialog, dressed for the one moment
   it matters -- a new subscriber's first screen. Text only, no icons:
   a slim accent rule in the app's own right-hand note-blue standing in
   for a mark, a serif display title (the same serif the chord analyzer
   already uses for its readout -- this app's own way of setting a
   moment apart from the rest of the neutral UI type), generous
   leading, and a hairline that separates the greeting from the choice
   underneath. */
.confirm-dialog.confirm-welcome {
  width: 440px;
  max-width: calc(100vw - 32px);
  padding: 38px 40px 28px;
  text-align: center;
}
/* The one accent this dialog spends: a short rule in the app's own
   note-blue, doing an icon's job (marking "something new") without
   being one. */
.confirm-dialog.confirm-welcome::before {
  content: '';
  display: block;
  width: 44px;
  height: 3px;
  margin: 0 auto 22px;
  background: var(--hand-r);
  border-radius: 2px;
}
#confirmTitle { font-size: 17px; font-weight: 600; margin-bottom: 10px; }
#confirmTitle.hidden { display: none; }
/* Targets the #id directly, not just the h3 tag -- the earlier version
   of this rule (.confirm-dialog.confirm-welcome h3) lost to the plain
   #confirmTitle rule above on specificity, silently, since every
   property it didn't set (letter-spacing) still came through while
   font-size and font-weight quietly reverted to the small default.
   Adding the #id here outranks that rule on purpose, and scopes the
   bigger, serif treatment to only this variant -- every other confirm
   dialog (delete confirmations, etc.) keeps its small, plain title. */
.confirm-dialog.confirm-welcome #confirmTitle {
  font-family: ui-serif, Georgia, 'Times New Roman', serif;
  font-size: 30px;
  font-weight: 700;
  letter-spacing: -0.01em;
  line-height: 1.2;
  margin-bottom: 16px;
}
.confirm-dialog.confirm-welcome #confirmMessage {
  font-size: 15px;
  line-height: 1.7;
  color: var(--text-secondary);
  white-space: pre-line;          /* the blank line between paragraphs */
  max-width: 40ch;
  margin: 0 auto;
}
/* Hairline above the actions, so the greeting reads as its own block. */
.confirm-dialog.confirm-welcome .confirm-actions {
  margin-top: 28px;
  padding-top: 20px;
  border-top: 1px solid var(--border-soft);
  justify-content: center;
  gap: 18px;
}
/* Cancel steps back to a plain text link here -- this is an offer, not
   a real fork, so the two buttons should not carry the equal visual
   weight a delete confirmation's do. */
.confirm-dialog.confirm-welcome #confirmCancelBtn {
  background: none;
  border: none;
  box-shadow: none;
  color: var(--text-secondary);
  font-weight: 500;
}
.confirm-dialog.confirm-welcome #confirmCancelBtn:hover {
  background: none;
  color: var(--text-primary);
}
/* The CTA picks up the same note-blue as the rule above it -- this
   dialog's one accent, repeated rather than joined by a second color. */
.confirm-dialog.confirm-welcome #confirmOkBtn {
  padding: 0 26px;
  font-weight: 600;
  background: var(--hand-r);
  border-color: var(--hand-r-strong);
  color: var(--hand-r-contrast);
}
.confirm-dialog.confirm-welcome #confirmOkBtn:hover { background: var(--hand-r-strong); }

/* ---- Android phones & tablets (reported: "trouble moving the bars").
   Every pointer-dragged surface in the app already declares
   touch-action: none -- zoom slider, loop-move zone, practice handles,
   the on-screen keyboard, both color-picker panes, the analyzer head,
   the library column resizer -- EXCEPT the one under the most-used
   gesture of all: #canvasWrap, the scrub-drag surface. Without it the
   browser reads a finger-drag on the falling notes as page scroll,
   fires pointercancel, and kills the scrub mid-gesture, while every
   neighbouring drag works. One selector closes the gap. */
#canvasWrap { touch-action: none; }

/* Below ~900px nothing was REACHABLE: with no narrow-width rules the
   desktop layout just clipped -- Practice through Settings fell off
   the topbar's right edge and half the transport (Transpose onward,
   track chips, Hit Window, volume) off the transport's, with no
   affordance that more existed. Until the proper mobile layout (see
   the to-do list: hamburger, bottom sheet), the honest minimum is
   making both bars swipeable: every control reachable at any width,
   desktop rendering untouched above the breakpoint. Scrollbars hidden
   because a bar under the topbar reads as a rendering bug; the swipe
   affordance is the clipped half-button itself. */
@media (max-width: 900px) {
  #topbar { overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }
  #topbar::-webkit-scrollbar { display: none; }
  #topbar > * { flex: 0 0 auto; }
  /* The scroll buttons earn their place only here -- see their own
     comment in index.html. 44px wide rather than the desktop fade's 40,
     so the target clears the usual minimum, and the chevron appears so
     it reads as something to press rather than a gradient. */
  .transport-controls-fade-end, .transport-controls-fade-start {
    pointer-events: auto;
    width: 44px;
    touch-action: manipulation;
  }
  .transport-scroll-chevron { display: block; }
  /* Hidden rather than inert when there is nothing that way: a button
     that does nothing is worse than no button. opacity already does
     this for the fade; pointer-events follows it so a tap on a dead
     edge falls through to whatever is underneath. */
  .transport-controls-fade-end:not(.scrollable) ,
  .transport-controls-fade-start:not(.scrollable) { pointer-events: none; }
  .transport-row, .transport-row-controls-wrap { overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }
  .transport-row::-webkit-scrollbar, .transport-row-controls-wrap::-webkit-scrollbar { display: none; }
  .transport-row > * { flex: 0 0 auto; }
}

/* ---- App tooltips -------------------------------------------------
   Replaces the browser's native title bubble, which is unstyled, uses
   the OS font, appears after a fixed ~1s the app cannot change, and
   looks nothing like anything else on screen. Built from the same
   tokens as every other floating surface (finger-edit popup, dialogs)
   so it themes with the app instead of against it -- deliberately NOT
   fixed colours, or it would invert wrongly in light theme.

   The native bubble is suppressed by moving the title attribute off the
   element while it is hovered and putting it straight back on leave
   (see wireAppTooltips) -- there is no CSS or attribute that hides the
   native one while keeping title in place, and title has to stay in the
   DOM at rest for assistive tech and for the suite, which reads it. */
#appTooltip {
  position: fixed;
  z-index: 200;              /* above dialogs and popovers, below nothing */
  max-width: 280px;
  padding: 7px 11px;
  background: var(--surface);
  color: var(--text);
  border: 1px solid var(--border);
  border-radius: var(--radius-s);
  box-shadow: var(--shadow-m);
  font-family: 'Google Sans Flex', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  font-size: 12.5px;
  font-weight: 500;
  line-height: 1.35;
  letter-spacing: -0.005em;
  text-align: left;
  pointer-events: none;      /* must never eat the hover that opened it */
  opacity: 0;
  transform: translateY(3px);
  transition: opacity .12s ease, transform .12s ease;
}
#appTooltip.show { opacity: 1; transform: translateY(0); }
/* Hidden outright rather than merely transparent when there is nothing
   to show, so it can never sit invisibly over a control. */
#appTooltip.hidden { display: none; }


/* The "+" draw-notes panel can be dragged off a passage being edited.
   The grab cursor is on the panel itself, not its controls, matching
   where initNoteEditAddPanelDrag actually starts a drag -- a move cursor
   over a button you cannot drag from would be a lie. */
#noteEditAddPanel { cursor: grab; }
#noteEditAddPanel button, #noteEditAddPanel input, #noteEditAddPanel select { cursor: pointer; }
#noteEditAddPanel.is-dragging { cursor: grabbing; user-select: none; }


/* Denser add panel. With 24 chord qualities and now 12 "Play as"
   options, an unconstrained panel grew very wide with each row half
   empty. Capping the width makes every button group WRAP onto more
   lines, which fills the box instead of leaving blank space beside
   short rows, and keeps the panel narrow enough to sit at the left
   edge without covering the notes being drawn. */
#noteEditAddPanel { max-width: 330px; }
#noteEditAddPanel .net-tip-row { align-items: flex-start; row-gap: 4px; }
#noteEditAddPanel .note-edit-add-value-btns,
#noteEditAddPanel .note-edit-reassign-tracks { display: flex; flex-wrap: wrap; gap: 4px; flex: 1 1 auto; min-width: 0; }
#noteEditAddPanel .net-row-label { flex: 0 0 auto; }


/* A short canvas (small window, or the browser zoomed in) could leave the
   add panel taller than the space it sits in, with the lower rows simply
   unreachable -- there was no scroll at all. Capped to the canvas height
   with its own scroll, and overscroll-behavior stops a flick inside the
   panel from scrolling the page behind it. */
#noteEditAddPanel { max-height: calc(100% - 16px); overflow-y: auto; overscroll-behavior: contain; }


/* Confirm-dialog note row: a small warning glyph and one line, sitting
   under the body text. Icon and text are baseline-aligned rather than
   centred so the glyph sits with the first line when the text wraps. */
.confirm-note {
  display: flex; align-items: flex-start; justify-content: center; gap: 7px;
  margin: 14px 0 0; font-size: 13px; line-height: 1.45;
  color: var(--text-secondary);
}
.confirm-note svg { flex: 0 0 auto; margin-top: 1px; opacity: 0.75; }
.confirm-note.hidden { display: none; }

/* ---- Windowed-keyboard mini-map -----------------------------------
   Only ever visible below the windowing breakpoint (the element is
   created in JS and carries .hidden otherwise), so nothing here can
   reach the desktop layout.

   Deliberately quiet: it is an orientation aid sitting directly above
   the keyboard, and at full contrast it would compete with the falling
   notes for attention at exactly the moment the notes matter. The lens
   is the only part with any real weight, because the lens is the part
   carrying information -- where on the piano you currently are.

   Height is small but the touch target is not: the padding block below
   gives it a ~28px grab area while the visible strip stays 10px, which
   is the usual trick for a control that should be easy to hit and
   almost invisible at rest. */
#keyWindowMap {
  position: relative;
  flex: 0 0 auto;
  height: 10px;
  margin: 0 0 2px;
  padding: 9px 0;
  box-sizing: content-box;
  background: var(--bg-canvas-bottom);
  border-top: 1px solid var(--border-soft);
  cursor: pointer;
  touch-action: none;   /* same reason as #canvasWrap: a drag here is a drag, not a page scroll */
  user-select: none;
}
#keyWindowMap.hidden { display: none; }
#keyWindowMap::before {
  /* The piano, as a single faint rule. Anything more detailed is
     illegible at 10px and only adds noise. */
  content: '';
  position: absolute;
  left: 0; right: 0; top: 50%;
  height: 2px;
  transform: translateY(-50%);
  background: var(--border);
  border-radius: 1px;
}
#keyWindowLens {
  position: absolute;
  top: 9px;
  height: 10px;
  min-width: 8px;
  border-radius: 3px;
  background: var(--accent);
  opacity: 0.55;
  transition: left var(--dur-fast) var(--ease-standard), width var(--dur-fast) var(--ease-standard);
}
/* Held by hand rather than following the music -- worth showing,
   because "why has it stopped moving" is otherwise a puzzle. */
#keyWindowMap.is-manual #keyWindowLens {
  opacity: 0.9;
  box-shadow: 0 0 0 1px var(--accent-strong);
}
@media (prefers-reduced-motion: reduce) {
  #keyWindowLens { transition: none; }
}

/* ---- Touch targets on phones --------------------------------------
   Measured, not guessed: measure_tap_targets.js reports the rendered
   rect of every interactive element at 390x844. 37 controls came back
   under 44px on BOTH axes, from 38px topbar buttons down to 13px
   count-in spinner arrows.

   The tool here is a transparent ::after overlay rather than a bigger
   button. Growing the visible controls to 44px would reflow the
   topbar, the analyzer card, the settings rows and the library table
   all at once -- a large visual change to buy an ergonomic one, and a
   large surface for the 129 desktop tests to disagree with. The
   overlay leaves every layout byte-identical and only widens what the
   finger can land on, which is the actual complaint.

   All of it sits inside the same 900px gate the windowed keyboard
   uses, for the same reason: 137 suite files run at 1400px and one at
   1280px, so none of this is on their path.

   The overlay is CENTRED and capped at 44px. It is deliberately not
   larger: two neighbouring controls with overlapping hit areas steal
   each other's taps, and a tap that activates the wrong thing is worse
   than one that misses. Where controls sit closer together than 44px
   the row itself is opened up instead -- see the library rules below. */
@media (max-width: 900px) {
  .icon-btn,
  .panel-close,
  .slider-icon-btn,
  .analyzer-toggle,
  .timer-stepper-btn,
  .streak-year-btn,
  .track-options-btn,
  .theme-swatch,
  .mini-switch,
  .pill-btn,
  .loop-bookmark-add-btn,
  .song-key-btn {
    position: relative;
  }
  .icon-btn::after,
  .panel-close::after,
  .slider-icon-btn::after,
  .analyzer-toggle::after,
  .timer-stepper-btn::after,
  .streak-year-btn::after,
  .track-options-btn::after,
  .theme-swatch::after,
  .mini-switch::after,
  .pill-btn::after,
  .loop-bookmark-add-btn::after,
  .song-key-btn::after {
    content: '';
    position: absolute;
    left: 50%;
    top: 50%;
    width: 44px;
    height: 44px;
    min-width: 100%;
    min-height: 100%;
    transform: translate(-50%, -50%);
    /* No background, no border: this is a hit area, not a visual. */
  }
  /* .mini-switch already sets position: relative and paints its knob
     with ::after -- overriding that would erase the knob. It gets
     ::before instead, which it does not use. */
  .mini-switch::after { content: none; }
  .mini-switch::before {
    content: '';
    position: absolute;
    left: 50%;
    top: 50%;
    width: 44px;
    height: 44px;
    min-width: 100%;
    min-height: 100%;
    transform: translate(-50%, -50%);
  }

  /* The library table is the one place the overlay cannot do the job:
     its play and delete buttons are ~26px in a row shorter than 44, so
     44px overlays would reach into the rows above and below and a tap
     meant for one song would sometimes play another. The row is given
     real height instead. Costs vertical space on the screen with the
     least of it, which is why it is the exception and not the rule. */
  .lt-row td, .library-table tbody td { height: 44px; }
  .lt-play-btn, .song-delete-btn { width: 32px; height: 32px; }
}

/* ---- Phone overflow menu ------------------------------------------
   The hamburger and its menu are display:none above the breakpoint, so
   the desktop topbar is untouched -- same gate, same reason, as the
   windowed keyboard and the tap targets.

   The menu is positioned, not inline, because the topbar is a
   horizontal SCROLL container below 900px: a dropdown laid out inside
   it would scroll away with the bar and could be clipped by its
   overflow. Fixed to the viewport instead, anchored under the bar. */
#topbarMenuBtn { display: none; }
#topbarMenu { display: none; }
@media (max-width: 900px) {
  #topbarMenuBtn { display: inline-flex; }
  #topbarMenuBtn.hidden { display: none; }
  #topbarMenu {
    display: flex;
    position: fixed;
    top: var(--topbar-h);
    right: 8px;
    z-index: 120;          /* above the bar, below .panel-scrim's 150 confirm layer */
    flex-direction: column;
    align-items: stretch;
    gap: 6px;
    min-width: 176px;
    padding: 8px;
    background: var(--surface);
    border: 1px solid var(--border);
    border-radius: var(--radius-m);
    box-shadow: var(--shadow-l);
  }
  #topbarMenu.hidden { display: none; }
  /* Moved buttons were sized for a horizontal bar. In a vertical menu
     they should fill the row and read left-aligned like menu items,
     which is a presentation change only -- the elements themselves are
     the originals. */
  #topbarMenu > button {
    width: 100%;
    justify-content: flex-start;
    min-height: 44px;
    gap: 10px;
  }
}

/* ---- Analyzer on phones -------------------------------------------
   NOT a bottom sheet, which is what the to-do list called for and what
   I would have built without measuring the screen first. On a phone the
   bottom of the viewport is the KEYBOARD -- the app's only input on iOS
   -- and the bottom of the canvas is the hit line, where the notes
   arrive. A sheet rising from the bottom covers the two things that
   must never be covered.

   Docked across the TOP of the canvas instead, full width and one row
   tall. That is the cheapest part of the canvas: a note spends about a
   second up there before it matters, and it now emerges from under a
   bar rather than popping in at a hard edge. The old placement was
   176px wide -- 45% of a 390px screen -- sitting over exactly the area
   where notes enter, which is worse on both counts.

   Collapsed by default (see the app.js note), so the resting state is
   one readable line. Tapping the head expands it downward over the
   canvas, which is a deliberate, momentary trade the person chose. */
@media (max-width: 900px) {
  #analyzerCard {
    top: 0;
    left: 0;
    right: 0;
    width: auto;
    max-width: none;
    border-top: none;
    border-left: none;
    border-right: none;
    border-radius: 0 0 var(--radius-m) var(--radius-m);
  }
  /* The head is the whole hit target when collapsed, and 44px here also
     un-clips #analyzerCollapseBtn: its hit overlay was being cut by
     #analyzerCard's own overflow because the head row was shorter than
     the overlay. Two problems, one number. */
  #analyzerCard .analyzer-head {
    min-height: 44px;
    align-items: center;
  }
  /* Restored at this width: the title was hidden below 760px to save
     horizontal space in a 176px card. Full width has room for it, and
     a bar with no label is a mystery bar. */
  #analyzerCard .analyzer-title { display: block; }
  /* Capped so an expanded analyzer can never swallow the whole canvas;
     past this it scrolls inside itself. */
  #analyzerCard .analyzer-body { max-height: 45vh; overflow-y: auto; }
}

/* ---- Sticky library table header ----------------------------------
   The table scrolls in its own bounded box (.library-table-wrap), so
   with eleven songs the column headings and the filter row scrolled
   away and you were reading an unlabelled grid.

   Two header rows, so two different `top` values, and the second one
   cannot be a constant: the first row's height depends on the font,
   the locale's word lengths and whether Google Sans Flex has loaded
   yet. It is measured after render and published as --lt-head-h (see
   updateLibraryStickyHeader). A hardcoded value would be right in
   English and wrong in German.

   z-index layering matters here because the first and last columns are
   ALREADY sticky horizontally. Header cells sit above body cells, and
   the four corner cells -- sticky in both axes at once -- sit above
   everything, or they would be overrun by whichever neighbour scrolled
   under them.

   The heading row's border-bottom is replaced by a box-shadow on the
   sticky cells. With border-collapse: collapse the border belongs to
   the table rather than the cell, so it does NOT travel with a sticky
   header and the row would scroll over the content with no seam. */
.library-table thead tr:first-child th {
  position: sticky;
  top: 0;
  z-index: 5;
  background: var(--surface);
}
.library-table thead tr.library-filter-row th {
  position: sticky;
  top: var(--lt-head-h, 30px);
  z-index: 5;
  background: var(--surface);
  box-shadow: 0 1px 0 var(--border);
}
/* Corner cells: sticky vertically AND horizontally. Above both. */
.library-table thead tr:first-child th:first-child,
.library-table thead tr:first-child th:last-child,
.library-table thead tr.library-filter-row th:first-child,
.library-table thead tr.library-filter-row th:last-child {
  z-index: 6;
}
.library-table thead tr:first-child th:first-child,
.library-table thead tr.library-filter-row th:first-child { left: 0; box-shadow: 1px 0 0 var(--border-soft); }
.library-table thead tr:first-child th:last-child,
.library-table thead tr.library-filter-row th:last-child { right: 0; box-shadow: -1px 0 0 var(--border-soft); }

/* "Upload a folder" inside the library dropzone, on its own line beneath the
   drag-and-drop sentence. */
.folder-upload-btn { display: flex; margin: 10px auto 0; height: 32px; font-size: 13px; }
