:root {
  --bg: #191925;
  --surface: #F5F1E8;
  --ink: #232132;
  --muted: #8B889C;
  --teal: #3FA88C;
  --amber: #E8A33D;

  /* How big the lyrics are drawn, as a multiplier on every size in the sung
     line and the full-lyrics overlay - hanzi, the pinyin under each word, and
     the English. One number rather than a second set of font sizes: the three
     have to stay in proportion to each other, and a duplicated size list is a
     list that eventually disagrees with itself.

     The class is set on <html> by applyLyricTextSize() in app.js. 1.3 rather
     than something gentler because the point of asking is that the default was
     not big enough - a 10% bump would leave the person who went looking for
     this setting exactly where they started. */
  --lyric-scale: 1;
}
:root.lyrics-large { --lyric-scale: 1.3; }
* { box-sizing: border-box; }
body {
  margin: 0;
  background: var(--bg);
  color: #EFEDE6;
  font-family: 'Manrope', sans-serif;
}
/* Sticky at every width, not just on a phone.
   The gear is the only way to reach settings, and it used to scroll away on a
   desktop window - where this was previously unpositioned. That bites hardest
   in the player: opening a word card adds 46vh of bottom padding to <main>
   (see the note on #word-card), which makes a page scroll that otherwise
   wouldn't, so reading the card scrolls the header off the top and the gear
   with it. Nothing looks broken - the control is simply gone.

   z-index has to clear .nav-bar's 40. That bar is also sticky at top: 0, so
   without this it slides up over the header and covers the gear even when the
   header itself has stayed put. */
.topbar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: 16px 20px;
  border-bottom: 1px solid rgba(255,255,255,0.08);
  position: sticky;
  top: 0;
  z-index: 50;
  background: var(--bg);
}
.topbar h1 { font-size: 18px; margin: 0; }
.topbar h1 span { color: var(--teal); }
main { padding: 20px; max-width: 720px; margin: 0 auto; }
button {
  background: var(--teal);
  color: #fff;
  border: none;
  border-radius: 8px;
  padding: 10px 16px;
  font-weight: 600;
  cursor: pointer;
  font-family: inherit;
}
button.secondary { background: transparent; border: 1px solid var(--muted); color: var(--muted); }
.filter-bar { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
.filter-bar button { background: rgba(255,255,255,0.06); color: #EFEDE6; }
.filter-bar button.active { background: var(--teal); }

/* ---- Section tabs ---------------------------------------------------------
   The app has four places, and these are them. They used to be a row of
   identical pill buttons, which says "four things you can do" and never
   answers "where am I" - the question you have after two taps into a playlist.
   Tabs answer it: one is attached to the page you are on, the others are not.

   Sticky, because the answer has to still be there once you've scrolled a
   hundred songs down. */
.nav-bar {
  position: sticky;
  /* Sticks BELOW the header rather than under it. Both are sticky and the
     header now outranks this on z-index, so with top: 0 the tabs would slide
     underneath it and disappear - which is the one thing this bar exists not to
     do. The offset is measured from the real header at runtime (see
     syncTopbarOffset in app.js) rather than hardcoded, because the header's
     height changes with the breakpoint and a magic number here would drift the
     moment anyone touched its padding. */
  top: var(--topbar-h, 0px);
  z-index: 40;
  display: flex;
  gap: 2px;
  /* Pulled out to the edges of <main>'s padding so the bar's background covers
     the full width as content scrolls underneath it, then given that padding
     back on the inside so the first tab still lines up with the content. */
  margin: -20px -20px 20px;
  padding: 0 20px;
  background: var(--bg);
  border-bottom: 1px solid rgba(255,255,255,0.12);
  overflow-x: auto;
  scrollbar-width: none;
}
.nav-bar::-webkit-scrollbar { display: none; }
.nav-tab {
  background: transparent;
  border: 0;
  border-radius: 0;
  border-bottom: 2px solid transparent;
  margin-bottom: -1px; /* sit on the bar's own border rather than above it */
  color: var(--muted);
  font-size: 15px;
  font-weight: 600;
  padding: 14px 14px 12px;
  white-space: nowrap;
}
.nav-tab:hover { color: #EFEDE6; }
.nav-tab.active {
  color: #EFEDE6;
  border-bottom-color: var(--teal);
}
@media (max-width: 420px) {
  .nav-tab { font-size: 14px; padding: 13px 9px 11px; }
}
.song-list { display: flex; flex-direction: column; gap: 10px; }
.song-card {
  display: flex;
  justify-content: space-between;
  align-items: center;
  background: rgba(255,255,255,0.05);
  border-radius: 10px;
  padding: 14px 16px;
  cursor: pointer;
}
.hsk-badge {
  background: var(--amber);
  color: #1a1a1a;
  border-radius: 6px;
  padding: 2px 8px;
  font-size: 12px;
  font-weight: 700;
}
/* One line at a time, in three layers: characters, pinyin beneath each word,
   and the English translation under the whole line. The faded preview lines are
   gone - three layers on the sung line is already as much as anyone reads at
   singing speed.

   min-height is doing real work here. The English is variable-length and wraps
   to two or three rows on a phone, so without a floor the block would jump
   every time the song moved between a short line and a long one. The value
   allows for the worst realistic case rather than the average, which means some
   short lines sit in a slightly tall block - the right trade against a lyric
   that shifts the page under your thumb. */
.lyric-stack {
  font-family: 'Noto Sans SC', sans-serif;
  text-align: center;
  margin: 24px 0;
  /* Scaled with the text: the floor exists to stop the block jumping between a
     short line and a long one, and a fixed floor under larger type would let it
     start jumping again. */
  min-height: calc(190px * var(--lyric-scale));
}
.lyric-row { line-height: 1.5; }

/* The translation. Latin face, quieter than the pinyin, and capped in width so
   a long line breaks into a readable column instead of running the full width
   of a desktop window. */
.lyric-en {
  font-family: 'Manrope', sans-serif;
  font-size: calc(14px * var(--lyric-scale));
  line-height: 1.45;
  color: rgba(239,237,230,0.78);
  margin: 12px auto 0;
  /* In em, so the column stays the same number of characters wide at either
     size rather than growing to hold the same number of pixels. */
  max-width: 34em;
}
/* The sung line is a wrapping flex row so each word can stack its pinyin
   underneath and stay aligned with it however the line breaks. The upcoming
   rows stay ordinary block text - they carry no pinyin and nothing to align. */
.lyric-row.current {
  font-size: calc(24px * var(--lyric-scale));
  margin-bottom: 10px;
  animation: lyric-advance 0.32s ease-out;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: flex-end;
  row-gap: 6px;
}

@keyframes lyric-advance {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .lyric-row.current { animation: none; }
}

.song-heading { font-size: 19px; margin: 4px 0 14px; line-height: 1.35; }
.song-heading span { display: block; font-size: 14px; font-weight: 400; color: var(--muted); }

/* Level picker: a dropdown rather than seven buttons, which wrapped onto two
   rows on a phone and cost more height than the lyrics underneath them. */
.level-picker { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.level-picker label { font-size: 13px; color: var(--muted); }
.level-picker select {
  background: rgba(255,255,255,0.08);
  color: #EFEDE6;
  border: 1px solid rgba(255,255,255,0.16);
  border-radius: 8px;
  padding: 7px 10px;
  font-family: inherit;
  font-size: 14px;
}
.level-picker select option { background: var(--bg); color: #EFEDE6; }
.word {
  cursor: pointer;
  /* Roomier than it looks: these are tap targets on a phone. */
  padding: 2px 3px;
  border-radius: 4px;
  touch-action: manipulation; /* no 300ms double-tap-to-zoom delay */
  -webkit-tap-highlight-color: transparent;
  /* Hanzi over pinyin. Stacking them inside the word - rather than running a
     second row under the line - is what keeps a syllable under its own
     character when a long line wraps. */
  display: inline-flex;
  flex-direction: column;
  align-items: center;
}
.word .hz { line-height: 1.25; }
/* Latin, so it takes the body face rather than the CJK one, and quiet enough
   that the characters stay the thing being read. */
.word .py {
  font-family: 'Manrope', sans-serif;
  font-size: calc(12px * var(--lyric-scale));
  line-height: 1.35;
  color: rgba(239,237,230,0.62);
  white-space: nowrap;
  /* An empty reading still holds its line, so a character the dictionary has
     nothing for doesn't leave the row sitting unevenly. */
  min-height: 1.35em;
}
/* "All" mode: every word in the wordlist, the original behaviour. */
.word.hsk-highlight { background: rgba(63,168,140,0.3); }
/* Words at exactly the selected level - what you're working on now. */
.word.hsk-target { background: rgba(63,168,140,0.45); box-shadow: inset 0 -2px 0 var(--teal); }
/* One level up - a stretch goal, deliberately quieter than the target colour
   so the two are distinguishable at a glance while singing along. */
.word.hsk-stretch { background: rgba(232,163,61,0.22); box-shadow: inset 0 -2px 0 var(--amber); }
/* Characters with no dictionary entry: shown so the line reads whole, but
   there's nothing to open, so don't invite a tap. */
.word.plain { cursor: default; }
/* A single-character word the segmenter is not confident about, because it sits
   against a character that is in neither HSK list - 张 inside 嚣张, which is not
   the word 张 meaning "sheet of paper".
   The background is dropped entirely and replaced with a dotted underline: the
   word can still be tapped, but it must not read as "vocabulary at your level"
   when the segmentation itself is a guess. Declared after the three highlight
   rules so it overrides whichever one applied. */
.word.uncertain {
  background: none;
  box-shadow: none;
  border-bottom: 1px dotted var(--muted);
}

/* ---- Idiom spans ----
   A run of words that together mean something their definitions don't:
   一见钟情 is not "one / see / clock / feeling".

   Deliberately colourless. The HSK highlights are filled teal and amber and
   they mean something specific - your level, and one above it. An idiom has no
   level at all, so borrowing either colour would say something false about it.
   A dashed rule and an outlined label read as structure rather than as a third
   level.

   DERIVED FROM currentColor, NOT HARDCODED. These spans are drawn on two
   surfaces with opposite polarity: the sung line sits on --bg (#191925, cream
   ink) and .lyrics-overlay sits on --surface (#F5F1E8, dark ink). Written as
   literal cream, the rule and the label were invisible on the overlay - the
   idiom read as a blank gap under the words, which is exactly what it looked
   like. currentColor resolves per surface and cannot drift out of step with a
   future one.

   Each colour is declared twice: the literal is the fallback for a browser
   without color-mix(), which simply ignores the second declaration. */
.idiom {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
}
.idiom-words {
  display: flex;
  align-items: flex-end;
  border-bottom: 2px dashed rgba(239,237,230,0.45);
  border-bottom-color: color-mix(in srgb, currentColor 45%, transparent);
  padding-bottom: 3px;
}
/* The label is the tap target, and the only one that opens the phrase card -
   the words above it keep their own taps. Sized well past the 44px minimum so
   it can be hit while the song is moving. */
.idiom-tag {
  margin-top: 4px;
  background: transparent;
  border: 1px solid rgba(239,237,230,0.38);
  border-color: color-mix(in srgb, currentColor 40%, transparent);
  border-radius: 999px;
  color: rgba(239,237,230,0.72);
  color: color-mix(in srgb, currentColor 72%, transparent);
  font-family: 'Manrope', sans-serif;
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  line-height: 1;
  padding: 6px 14px;
  min-width: 56px;
  min-height: 28px;
  cursor: pointer;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
}
.idiom-tag:hover, .idiom-tag:focus-visible {
  border-color: rgba(239,237,230,0.7);
  border-color: color-mix(in srgb, currentColor 70%, transparent);
  color: #EFEDE6;
  color: inherit;
}

.level-legend { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: var(--muted); }
.level-legend[hidden] { display: none; }
.level-legend .swatch { display: inline-block; width: 12px; height: 12px; border-radius: 3px; }
.level-legend .swatch.hsk-target { background: rgba(63,168,140,0.45); border-bottom: 2px solid var(--teal); }
.level-legend .swatch.hsk-stretch { background: rgba(232,163,61,0.22); border-bottom: 2px solid var(--amber); margin-left: 8px; }

/* ---- The two topbar menus ----
   The gear is what the player does; the avatar is who you are. They were one
   panel until v47, which put "show the English line" and "log out" in the same
   list and made the way out of the app something you had to read past.

   The gear no longer carries a signed-out dot. It doesn't need one: the header
   now shows a "Log in" link in words, which is more discoverable than a dot on
   an unrelated icon ever was. */
.topbar-actions { display: flex; align-items: center; gap: 10px; }
.icon-btn {
  background: rgba(255,255,255,0.08);
  border: 1px solid rgba(255,255,255,0.14);
  color: #EFEDE6;
  font-size: 17px;
  line-height: 1;
  padding: 9px 11px;
  border-radius: 10px;
  position: relative;
}
.icon-btn.open { background: var(--teal); border-color: var(--teal); }

/* Signed out. A word rather than a glyph - no icon says "log in" without being
   learned first, and this is aimed at someone who has not learned anything
   about this app yet. Styled as a link, not a filled button, so it doesn't
   compete with the primary action on whatever screen is underneath. */
.login-link {
  background: transparent;
  border: 1px solid rgba(255,255,255,0.22);
  color: #EFEDE6;
  font-size: 14px;
  padding: 8px 14px;
  border-radius: 999px;
}
.login-link:hover { background: rgba(255,255,255,0.09); border-color: rgba(255,255,255,0.4); }

/* Signed in. The initial of the display name on a teal disc - there is no
   avatar image in this app to draw instead, and a letter identifies the account
   in less space than a name. .avatar-disc is the same face, unclickable, at the
   head of the menu it opens. */
.avatar-btn,
.avatar-disc {
  width: 38px;
  height: 38px;
  flex: 0 0 auto;
  border-radius: 50%;
  background: var(--teal);
  color: #fff;
  border: none;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: inherit;
  font-size: 15px;
  font-weight: 700;
  line-height: 1;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.avatar-disc { cursor: default; }
.avatar-btn svg, .avatar-disc svg { fill: currentColor; }
.avatar-btn:hover { filter: brightness(1.1); }
.avatar-btn.open { box-shadow: 0 0 0 3px rgba(63,168,140,0.35); }
.avatar-btn:focus-visible,
.login-link:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }

/* ---- The dropdowns ----
   Both panels were blocks in the flow between the header and <main> until v47.
   The header is sticky and they were not, so opening the gear from the bottom
   of a long library inserted a panel far above the viewport and nothing
   appeared to happen at all. Fixed and anchored under the header, they answer
   where they were asked from.

   Right-aligned to the icons rather than full-bleed: the header is 20px in from
   the edge, so the menu lines up under the control that opened it. */
.topbar-panel {
  position: fixed;
  top: var(--topbar-h, 56px);
  right: 20px;
  z-index: 90;
  width: min(340px, calc(100vw - 32px));
  max-height: calc(100vh - var(--topbar-h, 56px) - 24px);
  overflow-y: auto;
  background: #2C2A3C;
  border: 1px solid rgba(255,255,255,0.13);
  border-radius: 14px;
  box-shadow: 0 18px 44px rgba(0,0,0,0.55);
  padding: 14px 16px;
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.topbar-panel[hidden] { display: none; }
/* Under the panels, over everything else including the sticky nav bar and the
   transport. Stops short of the header so the icon you tapped is still lit and
   still tappable while its menu is open. */
.panel-scrim {
  position: fixed;
  top: var(--topbar-h, 56px);
  left: 0; right: 0; bottom: 0;
  z-index: 80;
  background: rgba(12,12,20,0.45);
}
.panel-scrim[hidden] { display: none; }

.settings-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.settings-label { font-size: 13px; color: var(--muted); min-width: 62px; }
.settings-note {
  flex-basis: 100%;
  margin: 0;
  font-size: 12px;
  line-height: 1.5;
  color: var(--amber);
}

/* ---- The account menu ---- */
.profile-menu-head {
  display: flex;
  align-items: center;
  gap: 11px;
  padding-bottom: 12px;
  border-bottom: 1px solid rgba(255,255,255,0.1);
}
.profile-menu-who { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.profile-menu-who strong { font-size: 15px; }
.profile-menu-who span {
  font-size: 12px;
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.profile-menu-warn { color: var(--amber) !important; }
.profile-menu-items { display: flex; flex-direction: column; gap: 2px; }
/* Full-width rows, not a wrapped row of pills: a menu is a list of destinations
   and each one should have the same, obvious hit area. */
.menu-item {
  background: transparent;
  border: none;
  border-radius: 8px;
  color: #EFEDE6;
  font-size: 14px;
  font-weight: 600;
  text-align: left;
  padding: 11px 12px;
  width: 100%;
}
.menu-item:hover { background: rgba(255,255,255,0.08); }
.menu-item-quiet { color: var(--muted); }
.menu-item-quiet:hover { color: #EFEDE6; }
.settings-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.settings-actions button { padding: 8px 12px; font-size: 13px; }
/* The one checkbox in the gear. Given a rule of its own now that the panel is a
   340px dropdown rather than a full-width band: as a bare inline <label> the
   box and its sentence wrapped around each other, and the second line of text
   ran back under the box instead of lining up with the first. */
.checkbox-row {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  flex: 1 1 160px;
  min-width: 0;
  font-size: 13px;
  line-height: 1.4;
  cursor: pointer;
}
.checkbox-row input { flex: 0 0 auto; margin-top: 1px; accent-color: var(--teal); }
/* Segmented control reads as one setting with two states, rather than as two
   unrelated buttons the way the old bar did. */
.segmented { display: inline-flex; border: 1px solid rgba(255,255,255,0.16); border-radius: 9px; overflow: hidden; }
.segmented button {
  background: transparent;
  color: #EFEDE6;
  border: none;
  border-radius: 0;
  padding: 8px 14px;
  font-size: 13px;
}
.segmented button.active { background: var(--teal); }

.word-card-close {
  position: absolute;
  top: 10px; right: 12px;
  background: transparent;
  border: none;
  color: var(--ink);
  font-size: 26px;
  line-height: 1;
  padding: 4px 8px;
  cursor: pointer;
}
.word-card-loading { color: var(--muted); }
.popup {
  position: fixed;
  bottom: 0; left: 0; right: 0;
  background: var(--surface);
  color: var(--ink);
  padding: 24px;
  border-radius: 16px 16px 0 0;
  box-shadow: 0 -4px 20px rgba(0,0,0,0.3);
}
/* Centred so the eye doesn't have to travel from the middle of the lyric line
   out to a left margin to read the definition. */
#word-card {
  position: fixed;
  text-align: center;
  padding: 20px 48px;
  max-height: 45vh;
  overflow-y: auto;
  /* Above the lyrics overlay (58) and the transport (70). The card had no
     z-index at all while it was only ever opened over the plain page, where DOM
     order was enough - but words are tappable inside the overlay now, and an
     overlay with a stacking context beats an auto-z-index sibling whatever the
     order. Without this the definition opens *behind* the lyrics you tapped,
     which reads as the tap having done nothing. */
  z-index: 90;
}
#word-card h2 { margin: 0 0 6px; font-size: 26px; }
#word-card p { margin: 4px 0; }
/* The card is fixed to the bottom, so without this it sits on top of the last
   lyric line - which is exactly the line you just tapped a word in. */
main:has(#word-card) { padding-bottom: 46vh; }
.quiz-word { font-size: 32px; text-align: center; margin: 20px 0; font-family: 'Noto Sans SC', sans-serif; }
.quiz-actions { display: flex; gap: 12px; justify-content: center; }
.progress-row { display: flex; justify-content: space-between; padding: 10px 0; border-bottom: 1px solid rgba(255,255,255,0.08); }
.error-banner {
  background: rgba(217,83,79,0.15);
  border: 1px solid #d9534f;
  color: #f5b7b6;
  padding: 10px 14px;
  border-radius: 8px;
  margin-bottom: 16px;
  font-size: 14px;
}
.auth-box { max-width: 360px; margin: 20px auto; }
.auth-box label { display: block; font-size: 12px; color: var(--muted); margin: 12px 0 4px; }
.auth-box input[type=text], .auth-box input[type=password], .auth-box input[type=email] {
  width: 100%;
  padding: 10px 12px;
  border-radius: 8px;
  border: 1px solid rgba(255,255,255,0.15);
  background: rgba(255,255,255,0.05);
  color: #fff;
  font-family: inherit;
  font-size: 14px;
}
.auth-box button { width: 100%; margin-top: 16px; }
.auth-box a { color: var(--teal); }

/* ---- Phone layout ----
   The player and the lyrics are the point of this screen; everything else
   gives up space to them first. */
@media (max-width: 600px) {
  main { padding: 14px 16px; }
  /* Only the padding changes here now - sticky, z-index and background are set
     on .topbar itself for every width. The z-index: 5 this used to carry was
     BELOW .nav-bar's 40, so the nav bar rode up over the gear on a phone. */
  .topbar { padding: 12px 16px; }
  .topbar h1 { font-size: 16px; }
  /* Nearly full width at this size, still hanging off the header. A 340px card
     inset from both edges of a 375px screen leaves rows too narrow for the
     "Play via" segmented control to sit beside its label. */
  .topbar-panel { right: 12px; width: calc(100vw - 24px); padding: 12px 14px; }
  .topbar-actions { gap: 8px; }
  .nav-bar { margin-bottom: 12px; }
  .nav-bar button { flex: 1; padding: 9px 10px; font-size: 14px; }
  .song-heading { font-size: 17px; }
  /* Allows for the pinyin row and a translation that wraps to three rows, which
     is what a long line does at this width. */
  .lyric-stack { margin: 18px 0; min-height: calc(190px * var(--lyric-scale)); }
  .lyric-row.current { font-size: calc(22px * var(--lyric-scale)); }
  .lyric-en { font-size: calc(13px * var(--lyric-scale)); margin-top: 10px; }
  /* Pinyin is a reading aid, not the lyric. It gives up size first on a narrow
     screen so the characters keep theirs. */
  .word .py { font-size: calc(11px * var(--lyric-scale)); }
  .word { padding: 2px; }
  .finish-btn { width: 100%; margin-top: 4px; }
  .song-card { padding: 12px 14px; }
}

button, select { -webkit-tap-highlight-color: transparent; }

/* ---- Collapsible video ----
   Collapsed to zero height rather than display:none: a hidden iframe can have
   its playback suspended by the browser, and the audio is the whole point. */
.player-shell { transition: height 0.25s ease, opacity 0.2s ease; }
.player-shell.collapsed { height: 0; opacity: 0; overflow: hidden; pointer-events: none; }
/* .video-bar is gone; its controls live in .transport at the end of this file.
   .video-thumb survives - it moved to the now-playing row, which is the whole
   point of the change: collapsing the video now folds the artwork into a row
   that already existed rather than adding one. */
.ghost-btn {
  background: rgba(255,255,255,0.08);
  border: 1px solid rgba(255,255,255,0.14);
  color: #EFEDE6;
  border-radius: 8px;
  padding: 7px 12px;
  font-size: 13px;
  font-weight: 600;
}
.settings-row select {
  background: rgba(255,255,255,0.08);
  color: #EFEDE6;
  border: 1px solid rgba(255,255,255,0.16);
  border-radius: 8px;
  padding: 7px 10px;
  font-family: inherit;
  font-size: 13px;
}
.settings-row select option { background: var(--bg); color: #EFEDE6; }

/* ---- Library rows with album art ---- */
.song-card { gap: 12px; }
.song-card-text { flex: 1; min-width: 0; }
.song-card-text strong { display: block; }
/* Long Chinese titles shouldn't push the HSK badge off the row on a phone. */
.song-card-text strong,
.song-card-text span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; }
.song-card-text span { color: var(--muted); font-size: 13px; }
/* The wrapper carries the fallback, so a song with no art - or a dead art URL,
   whose <img> removes itself - still occupies the same space and the rows stay
   aligned. */
.song-thumb {
  width: 48px;
  height: 48px;
  flex: 0 0 48px;
  border-radius: 8px;
  background: rgba(255,255,255,0.08);
  overflow: hidden;
  position: relative;
}
.song-thumb::after {
  content: '♪';
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--muted);
  font-size: 20px;
}
.song-thumb img {
  position: relative;
  z-index: 1;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.level-count { font-size: 12px; color: var(--muted); margin-left: auto; }

/* ---- Definition card: step between the words of the line ---- */
.word-card-nav {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 16px;
  margin-top: 14px;
  font-size: 12px;
  color: rgba(35,33,50,0.55);
}
.word-card-nav button {
  background: rgba(35,33,50,0.08);
  color: var(--ink);
  border: none;
  border-radius: 8px;
  padding: 6px 16px;
  font-size: 20px;
  line-height: 1;
}
.word-card-nav button:disabled { opacity: 0.3; }

/* Spotify connect failures sit above the library rather than replacing it, so
   there's always a way forward. */
.auth-error { text-align: left; }
.auth-error-title { font-weight: 700; margin: 0 0 6px; }
.auth-error p { margin: 0 0 8px; }
.banner-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
.banner-actions button { padding: 8px 12px; font-size: 13px; }
.banner-actions button.secondary { color: #f5b7b6; border-color: rgba(245,183,182,0.5); }

/* Library search sits above the filters, full width - it's the control most
   likely to be reached for once the catalogue outgrows one screen. */
.library-search {
  width: 100%;
  background: rgba(255,255,255,0.07);
  border: 1px solid rgba(255,255,255,0.16);
  color: #EFEDE6;
  border-radius: 9px;
  padding: 10px 12px;
  font-family: inherit;
  font-size: 15px;
  margin-bottom: 10px;
}
.library-search::placeholder { color: var(--muted); }

/* Artwork in the collapsed video bar: with the video hidden, this is the only
   thing left showing what's playing. */
.video-thumb {
  width: 34px;
  height: 34px;
  flex: 0 0 34px;
  border-radius: 6px;
  overflow: hidden;
  background: rgba(255,255,255,0.08);
}
.video-thumb[hidden] { display: none; }
.video-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* ================= Progress page =================
   Palette note: known/learning are a status pair, not a categorical set, so
   they reuse the app's --teal and --amber. Checked with the dataviz validator
   against this surface: CVD separation 11.1 (protan), normal-vision 21.4, and
   contrast all PASS. The amber sits above the categorical lightness band, which
   is a rule about equal visual weight between peer series - here the two mean
   different things, amber deliberately draws the eye to unfinished work, and
   the same amber is already load-bearing elsewhere in the app. Every value is
   also printed as a number and available in the table view, so nothing is
   carried by colour alone. */

.stat-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 16px; }
.stat-tile {
  background: rgba(255,255,255,0.05);
  border-radius: 12px;
  padding: 14px 12px;
  text-align: center;
}
.stat-value {
  font-size: 28px;
  font-weight: 700;
  line-height: 1.1;
  /* Proportional figures: tabular-nums makes a large standalone number look
     gappy. Kept for the table view, where digits do need to align. */
  font-variant-numeric: proportional-nums;
}
.stat-label { font-size: 12px; color: var(--muted); margin-top: 4px; }
.stat-hint { font-size: 11px; color: var(--teal); margin-top: 2px; }

.milestone {
  background: rgba(63,168,140,0.12);
  border: 1px solid rgba(63,168,140,0.45);
  border-radius: 10px;
  padding: 12px 14px;
  font-size: 14px;
  margin: 0 0 16px;
}
.milestone.done { background: rgba(63,168,140,0.18); }

.panel { margin-bottom: 22px; }
.panel h3 { font-size: 15px; margin: 0 0 4px; }
.panel-sub { font-size: 12px; color: var(--muted); margin: 0 0 12px; }
.panel-empty { font-size: 13px; color: var(--muted); }

.chart-legend { display: flex; gap: 14px; flex-wrap: wrap; font-size: 12px; color: var(--muted); margin-bottom: 10px; }
.chart-legend .key { display: inline-flex; align-items: center; gap: 5px; }
.chart-legend .swatch { width: 10px; height: 10px; border-radius: 3px; }
.chart-legend .swatch.known { background: var(--teal); }
.chart-legend .swatch.learning { background: var(--amber); }
.chart-legend .swatch.untouched { background: rgba(255,255,255,0.1); }

/* Thin bars, generous spacing - saturated fills stay small rather than becoming
   heavy blocks. */
.level-bars { display: flex; flex-direction: column; gap: 10px; }
.level-bar-row { display: flex; align-items: center; gap: 10px; }
.level-bar-label { flex: 0 0 52px; font-size: 12px; color: var(--muted); }
.level-bar {
  flex: 1;
  display: flex;
  height: 14px;
  border-radius: 7px;
  background: rgba(255,255,255,0.1);
  overflow: hidden;
  cursor: default;
}
.level-bar:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
.level-bar .seg { height: 100%; }
.level-bar .seg.known { background: var(--teal); border-radius: 7px 0 0 7px; }
/* A 2px surface gap separates the segments instead of a border drawn round
   them. */
.level-bar .seg.learning { background: var(--amber); box-shadow: -2px 0 0 var(--bg); }
.level-bar-value { flex: 0 0 auto; font-size: 12px; font-variant-numeric: tabular-nums; min-width: 46px; text-align: right; }
.level-bar-value small { color: var(--muted); }

/* Sequential single hue, dimmest to brightest. Magnitude gets one hue, never a
   rainbow. */
.activity-grid {
  display: grid;
  grid-template-rows: repeat(7, 1fr);
  grid-auto-flow: column;
  gap: 3px;
  overflow-x: auto;
  padding-bottom: 4px;
}
.day-cell {
  width: 13px;
  height: 13px;
  border-radius: 3px;
  background: rgba(255,255,255,0.06);
  /* The hit target includes the gap, so a 13px cell is comfortably tappable. */
  box-shadow: 0 0 0 3px transparent;
}
.day-cell:focus-visible { outline: 2px solid var(--teal); outline-offset: 1px; }
.day-cell.level-1 { background: rgba(63,168,140,0.35); }
.day-cell.level-2 { background: rgba(63,168,140,0.65); }
.day-cell.level-3 { background: var(--teal); }
.activity-scale { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--muted); margin-top: 8px; }

.table-view { margin-top: 10px; font-size: 12px; }
.table-view summary { color: var(--muted); cursor: pointer; }
.table-view table { width: 100%; border-collapse: collapse; margin-top: 8px; font-variant-numeric: tabular-nums; }
.table-view th, .table-view td { text-align: left; padding: 4px 6px; border-bottom: 1px solid rgba(255,255,255,0.08); }
.table-view th { color: var(--muted); font-weight: 600; }

.recent-list { list-style: none; margin: 0; padding: 0; }
.recent-list li { display: flex; justify-content: space-between; align-items: center; gap: 10px; padding: 9px 0; border-bottom: 1px solid rgba(255,255,255,0.08); }
.recent-song { display: flex; flex-direction: column; min-width: 0; }
.recent-song small { color: var(--muted); font-size: 12px; }
.recent-score { font-variant-numeric: tabular-nums; font-weight: 700; }
.recent-score.perfect { color: var(--teal); }

.empty-state { background: rgba(255,255,255,0.05); border-radius: 12px; padding: 22px 18px; text-align: center; }
.empty-state-lead { font-size: 17px; font-weight: 700; margin: 0 0 8px; }
.empty-state p { color: var(--muted); font-size: 14px; margin: 0 0 10px; line-height: 1.5; }
.empty-state-meta { font-size: 12px; }

.chart-tip {
  position: fixed;
  z-index: 20;
  background: var(--surface);
  color: var(--ink);
  border-radius: 8px;
  padding: 6px 10px;
  font-size: 12px;
  pointer-events: none;
  box-shadow: 0 4px 14px rgba(0,0,0,0.35);
  max-width: 220px;
}
.chart-tip[hidden] { display: none; }

@media (max-width: 400px) {
  .stat-value { font-size: 24px; }
  .level-bar-label { flex-basis: 46px; }
}

/* ---- Quiz result: what actually changed ---- */
.result-score { font-size: 34px; font-weight: 700; margin: 4px 0 2px; font-variant-numeric: proportional-nums; }
.result-gain { font-size: 15px; margin: 0 0 12px; }
.result-gain.muted { color: var(--muted); font-size: 14px; }
.result-words { display: flex; flex-wrap: wrap; gap: 6px; margin: 0 0 18px; }
.result-words span {
  font-family: 'Noto Sans SC', sans-serif;
  background: rgba(63,168,140,0.25);
  border-radius: 6px;
  padding: 4px 9px;
  font-size: 17px;
}
.celebrate {
  background: linear-gradient(180deg, rgba(63,168,140,0.22), rgba(63,168,140,0.08));
  border: 1px solid var(--teal);
  border-radius: 12px;
  padding: 18px;
  text-align: center;
  margin-bottom: 18px;
}
.celebrate-mark { font-size: 28px; color: var(--teal); line-height: 1; margin-bottom: 6px; }
.celebrate p { margin: 2px 0; font-size: 14px; }

/* ---- Review pile ---- */
.review-prompt {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  background: rgba(232,163,61,0.12);
  border: 1px solid rgba(232,163,61,0.5);
  border-radius: 10px;
  padding: 12px 14px;
  margin-bottom: 16px;
  flex-wrap: wrap;
}
.review-prompt strong { display: block; font-size: 14px; }
.review-prompt span { font-size: 12px; color: var(--muted); }
.review-prompt button { padding: 8px 14px; font-size: 13px; }

.untouched-levels { margin-top: 10px; }
.untouched-levels summary { font-size: 12px; color: var(--muted); cursor: pointer; padding: 4px 0; }
.untouched-levels .level-bars { margin-top: 8px; }

/* ---- My library ---- */
.playlist-bar { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; flex-wrap: wrap; }
.playlist-bar select {
  flex: 1;
  min-width: 140px;
  background: rgba(255,255,255,0.08);
  color: #EFEDE6;
  border: 1px solid rgba(255,255,255,0.16);
  border-radius: 8px;
  padding: 9px 10px;
  font-family: inherit;
  font-size: 14px;
}
.playlist-bar select option { background: var(--bg); color: #EFEDE6; }
.small-btn { padding: 8px 12px; font-size: 13px; }

.add-panel { background: rgba(255,255,255,0.04); border-radius: 10px; padding: 12px 14px; margin-bottom: 14px; }
.add-panel summary { cursor: pointer; font-size: 14px; font-weight: 600; }
.add-form { margin-top: 12px; }
.add-form label { display: block; font-size: 12px; color: var(--muted); margin: 10px 0 5px; }
.add-row { display: flex; gap: 6px; flex-wrap: wrap; }
.add-row input {
  flex: 1;
  min-width: 120px;
  background: rgba(255,255,255,0.07);
  border: 1px solid rgba(255,255,255,0.16);
  color: #EFEDE6;
  border-radius: 8px;
  padding: 9px 11px;
  font-family: inherit;
  font-size: 14px;
}
.add-row input:disabled { opacity: 0.5; }
.add-row button { padding: 9px 14px; font-size: 13px; }
.field-note { font-size: 11px; color: var(--muted); margin: 10px 0 0; }

/* ---- "Choose from your Spotify playlists" ----
   Capped and scrollable rather than however tall fifty playlists happen to be:
   this opens inside the "Add songs" panel, and an unbounded list would push the
   playlist it is adding to off the screen entirely. */
#spotify-mine-btn { margin-top: 10px; }
.spotify-picker-list {
  margin-top: 10px;
  max-height: 46vh;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.spotify-picker-row {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 7px 8px;
  background: transparent;
  border: none;
  border-radius: 9px;
  color: inherit;
  text-align: left;
  cursor: pointer;
}
.spotify-picker-row:hover { background: rgba(255,255,255,0.07); }
.spotify-picker-row img,
.spotify-picker-blank {
  width: 40px; height: 40px;
  flex: 0 0 auto;
  border-radius: 6px;
  object-fit: cover;
}
/* Artwork is optional on a Spotify playlist, and a row that loses its image
   must keep the same height as its neighbours or the list jitters as covers
   load. */
.spotify-picker-blank {
  display: flex; align-items: center; justify-content: center;
  background: rgba(255,255,255,0.08);
  color: var(--muted);
}
.spotify-picker-text { display: flex; flex-direction: column; min-width: 0; }
.spotify-picker-text strong {
  font-size: 14px;
  overflow-wrap: anywhere;
}
.spotify-picker-text span { font-size: 12px; color: var(--muted); }
.ok-note { color: var(--teal); font-size: 13px; margin: 0 0 12px; }

/* Imported videos whose title couldn't be split into a song and an artist.
   Set apart from the ordinary add panel by a left rule rather than a colour
   wash: these are a prompt, not a warning - nothing has gone wrong, the app
   just needs one look before it queues them. */
.confirm-panel {
  border-left: 3px solid var(--teal);
  background: rgba(255,255,255,0.04);
  border-radius: 0 10px 10px 0;
  padding: 12px 14px;
  margin-bottom: 14px;
}
.confirm-lead { font-size: 13px; margin: 0 0 10px; color: #EFEDE6; }
.confirm-row + .confirm-row { margin-top: 12px; }
/* The original YouTube title, kept on one line so a long one can't push the
   fields around. It is reference material, not something to read in full. */
.confirm-raw {
  display: block;
  font-size: 11px;
  color: var(--muted);
  margin-bottom: 5px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.confirm-row .add-row button { flex: 0 0 auto; }

/* A song that isn't playable yet stays listed, dimmed, with its reason - a song
   you added yourself vanishing reads as the app losing it. */
.my-song { position: relative; padding-right: 42px; }
.my-song.not-ready { opacity: 0.62; cursor: default; }
.song-note { font-size: 11px; color: var(--amber); margin-top: 2px; }
.row-remove {
  position: absolute;
  top: 50%;
  right: 10px;
  transform: translateY(-50%);
  background: transparent;
  border: none;
  color: var(--muted);
  font-size: 22px;
  line-height: 1;
  padding: 4px 8px;
  cursor: pointer;
}
.row-remove:hover { color: #d9534f; }

/* ---- Per-song HSK mix ----
   HSK levels are an ordered scale, so this is an ordinal ramp: one hue stepping
   darker, never a categorical palette (which would imply the levels are
   unrelated categories rather than a progression). Every value is also in the
   title/aria text, so nothing depends on telling the steps apart. */
.level-mix { display: flex; align-items: center; gap: 7px; margin-top: 5px; }
.mix-bar {
  display: flex;
  flex: 0 0 78px;
  height: 6px;
  border-radius: 3px;
  overflow: hidden;
  background: rgba(255,255,255,0.08);
}
.mix-seg { min-width: 2px; }
.mix-1 { background: rgba(63,168,140,0.26); }
.mix-2 { background: rgba(63,168,140,0.38); }
.mix-3 { background: rgba(63,168,140,0.51); }
.mix-4 { background: rgba(63,168,140,0.64); }
.mix-5 { background: rgba(63,168,140,0.77); }
.mix-6 { background: rgba(63,168,140,0.90); }
/* Band 7 is the combined HSK 7-9 advanced stage, so it is the top of the ramp.
   The steps above were re-spaced from six to seven rather than squeezing a new
   one in, which would have made the last two indistinguishable. */
.mix-7 { background: var(--teal); }
/* The level you're working at gets the amber accent used for "learning"
   everywhere else, so the one segment that matters is findable at a glance. */
.mix-seg.mix-target { background: var(--amber); }
.mix-label { font-size: 11px; color: var(--muted); }
.mix-label.has-target { color: var(--amber); font-weight: 600; }

/* The quiz word doubles as a check-yourself control. */
.quiz-word { cursor: pointer; border-radius: 10px; padding: 6px 10px; }
.quiz-word:hover { background: rgba(255,255,255,0.06); }
.quiz-word:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }
.quiz-hint { text-align: center; color: var(--muted); font-size: 13px; }

/* ---- Brand ---- */
.brand { display: flex; align-items: center; gap: 11px; margin: 0; }
.brand-mark { display: block; flex: 0 0 auto; }
.brand-wordmark { display: block; height: 30px; width: auto; }

/* Marks the admin panel as a different place from the learner app. Uses the
   brand jade at low opacity rather than the amber: amber is a status colour
   here (known/learning, the signed-out gear dot) and a permanent amber chrome
   element would dilute what it means everywhere else. */
.admin-pill {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--teal);
  background: rgba(63, 168, 140, 0.15);
  border: 1px solid rgba(63, 168, 140, 0.45);
  border-radius: 999px;
  padding: 3px 9px;
  align-self: center;
}

/* ---- One control row per song list ----
   "My level" leads because it drives the counts, the coverage badge and the
   sort. The song-level filter moved into the disclosure below: it reads like
   the same thing and isn't, and having both on one line was the confusion. */
.list-controls-row { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.my-level, .sort-control { display: inline-flex; align-items: center; gap: 6px; }
.my-level > span, .sort-control > span { font-size: 12px; color: var(--muted); }
.my-level select, .sort-control select {
  background: rgba(255,255,255,0.08);
  color: #EFEDE6;
  border: 1px solid rgba(255,255,255,0.16);
  border-radius: 8px;
  padding: 7px 10px;
  font-family: inherit;
  font-size: 14px;
}
.my-level select { border-color: rgba(63,168,140,0.5); }
.my-level select option, .sort-control select option { background: var(--bg); color: #EFEDE6; }
.more-filters { margin-bottom: 14px; }
.more-filters summary { font-size: 12px; color: var(--muted); cursor: pointer; padding: 4px 0; }
.more-filters .sort-control { margin: 8px 0 0; }

/* ---- Coverage badge ----
   Replaces the bare HSK tag, which said "HSK 4" and meant "you need HSK 4 to
   follow all of it" - so a song could be badged 4 on the strength of three
   words. Banded rather than a continuous ramp: three states are what a reader
   can hold, and the number is always printed. */
.coverage-badge {
  border-radius: 6px;
  padding: 3px 9px;
  font-size: 12px;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.cov-easy { background: rgba(63,168,140,0.9); color: #10231d; }
.cov-ok { background: rgba(63,168,140,0.32); color: #EFEDE6; }
.cov-stretch { background: rgba(255,255,255,0.1); color: var(--muted); }

/* ---- Quiz success: the mark assembles itself ----
   Per the brand: the waveform bars collapse into the check, so every correct
   answer draws the logo. It's the name being demonstrated, not decoration. */
.success-mark { display: block; margin: 0 auto 10px; }
.success-mark .bar { animation: bar-collapse 0.5s ease-in forwards; }
.success-mark .bar-2 { animation-delay: 0.08s; }
.success-mark .tick {
  stroke-dasharray: 60;
  stroke-dashoffset: 60;
  animation: tick-draw 0.45s ease-out 0.34s forwards;
}
@keyframes bar-collapse {
  to { transform: translateX(14px) scaleY(0.2); opacity: 0; }
}
@keyframes tick-draw {
  to { stroke-dashoffset: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .success-mark .bar { animation: none; opacity: 0; }
  .success-mark .tick { animation: none; stroke-dashoffset: 0; }
}

@media (max-width: 400px) {
  /* The wordmark shrinks before the gear does - the settings control has to
     stay a full tap target. */
  .brand-wordmark { height: 25px; }
  .brand-mark { width: 34px; height: 34px; }
  .admin-pill { font-size: 10px; padding: 2px 7px; }
}

/* ---- Pin + Study ----------------------------------------------------------
   The word card grows two controls. They sit under the definition and are
   deliberately quiet: someone who tapped a word mid-song came for the meaning,
   not for a toolbar. */
.word-card-actions {
  display: flex;
  gap: 8px;
  justify-content: center;
  margin-top: 14px;
}
.pin-btn, .study-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  background: transparent;
  border: 1px solid var(--muted);
  color: var(--muted);
  border-radius: 999px;
  padding: 6px 14px;
  font-size: 13px;
  cursor: pointer;
}
.pin-btn:hover, .study-btn:hover { border-color: var(--ink); color: var(--ink); }
/* Amber, matching "learning" everywhere else: a pin is something you've set
   aside to come back to, not something you've finished. */
.pin-btn.pinned {
  background: rgba(232,163,61,0.18);
  border-color: var(--amber);
  color: #9A6B12;
  font-weight: 600;
}
.pin-btn.pinned .pin-icon { color: var(--amber); }
.pin-error { color: #B4453C; font-size: 12px; margin-top: 8px; }

.sr-only {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* The Study panel is a sheet over the player, never a view of its own — a
   re-render of #view would tear down the YouTube iframe and restart the song.
   Taller than the word card because it is read rather than glanced at.

   Three bands, stacked: the header, the index of pinned words, and the entry
   for whichever one is selected. The panel itself does not scroll — the index
   and the entry scroll independently, so picking a word never moves the list
   you are picking from, and filtering never moves what you are reading. */
.study-panel {
  position: fixed;
  bottom: 0; left: 0; right: 0;
  z-index: 60;
  display: flex;
  flex-direction: column;
  max-height: 86vh;
  overflow: hidden;
  background: var(--surface);
  color: var(--ink);
  border-radius: 16px 16px 0 0;
  box-shadow: 0 -4px 24px rgba(0,0,0,0.4);
  padding: 14px 20px 18px;
}
@media (min-width: 720px) {
  .study-panel {
    left: auto; right: 0; top: 0;
    width: 440px;
    height: 100vh;
    max-height: 100vh;
    border-radius: 16px 0 0 16px;
    box-shadow: -4px 0 24px rgba(0,0,0,0.4);
  }
}
.study-head {
  flex: 0 0 auto;
  display: flex; align-items: baseline;
  gap: 10px; margin-bottom: 10px;
}
.study-head h2 { margin: 0; font-size: 21px; }
.study-count { font-size: 12px; color: var(--muted); }
.study-close {
  margin-left: auto;
  background: transparent; border: 0; color: var(--muted);
  font-size: 28px; line-height: 1; cursor: pointer; padding: 0 4px;
}
.study-loading, .study-empty, .study-note {
  color: var(--muted); font-size: 14px; line-height: 1.5;
}

/* ---- The index of pinned words -------------------------------------------
   Always on screen. Inspecting one word after another is the job, and a list
   you have to navigate back to turns that into a round trip per word.

   Height-capped so it can never push the entry off the bottom of the sheet;
   the level filters are the real relief valve once the list is long. */
.study-index {
  flex: 0 0 auto;
  border-bottom: 1px solid rgba(35,33,50,0.14);
  padding-bottom: 10px;
  margin-bottom: 12px;
}
.study-index-empty { margin: 0 0 14px; }
.study-filters {
  display: flex; align-items: center; gap: 8px;
  margin-bottom: 10px;
}
.level-chips {
  flex: 1 1 auto;
  display: flex; gap: 6px;
  overflow-x: auto; scrollbar-width: none;
  padding-bottom: 2px;
}
.level-chips::-webkit-scrollbar { display: none; }
.level-chip {
  display: inline-flex; align-items: center; gap: 5px;
  background: transparent;
  border: 1px solid rgba(35,33,50,0.2);
  color: var(--muted);
  border-radius: 999px;
  padding: 5px 10px;
  font-size: 12px; font-weight: 600;
  white-space: nowrap;
}
.level-chip span { font-weight: 400; font-size: 11px; opacity: 0.75; }
.level-chip.on {
  background: rgba(63,168,140,0.14);
  border-color: var(--teal);
  color: #227A62;
}
.level-chip.reset { border-style: dashed; }
.study-quiz {
  flex: 0 0 auto;
  font-size: 12px; padding: 6px 12px;
  border-color: rgba(35,33,50,0.3); color: var(--ink);
}
.study-words { max-height: 26vh; overflow-y: auto; }
@media (min-width: 720px) {
  .study-words { max-height: 30vh; }
}

.study-level { margin-bottom: 12px; }
.study-level:last-child { margin-bottom: 0; }
.study-level h3 {
  font-size: 13px; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--muted); margin: 0 0 6px;
  display: flex; align-items: baseline; gap: 8px;
}
.study-level h3 span { font-weight: 400; }
.study-word-row { display: flex; flex-wrap: wrap; gap: 6px; }
.study-word {
  background: rgba(35,33,50,0.06);
  border: 1px solid rgba(35,33,50,0.12);
  color: var(--ink);
  border-radius: 8px; padding: 5px 9px;
  cursor: pointer; text-align: left;
}
.study-word:hover { border-color: var(--teal); }
/* Which word the entry below belongs to. Without this the panel shows an entry
   with no visible connection to the list it came from. */
.study-word.selected {
  background: rgba(63,168,140,0.16);
  border-color: var(--teal);
}
.sw-hanzi {
  display: block; font-size: 17px; line-height: 1.25;
  font-family: 'Noto Sans SC', sans-serif;
}
.sw-pinyin { display: block; font-size: 11px; color: var(--muted); }

/* ---- The entry ------------------------------------------------------------
   Takes whatever the index leaves and scrolls on its own. min-height:0 is
   load-bearing: without it a flex child refuses to shrink below its content
   and the entry pushes the sheet open instead of scrolling. */
.study-detail {
  flex: 1 1 auto;
  overflow-y: auto;
  min-height: 0;
}
.study-pick { padding: 16px 0; }

.study-word-head {
  display: flex; align-items: flex-start; justify-content: space-between;
  gap: 12px; margin-bottom: 6px;
}
/* The entry carries its own heading now that the panel header is permanently
   the word "Study". */
.swh-hanzi {
  font-family: 'Noto Sans SC', sans-serif;
  font-size: 30px; line-height: 1.1; margin-bottom: 2px;
}
.swh-pinyin { font-size: 17px; font-style: italic; }
.swh-level { font-size: 12px; color: var(--muted); }
.study-gloss { margin: 0 0 16px; font-size: 15px; }

.study-section { margin-bottom: 18px; }
.study-section h3 {
  font-size: 12px; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--muted); margin: 0 0 8px;
  border-bottom: 1px solid rgba(35,33,50,0.1); padding-bottom: 4px;
}
.sense-row { margin-bottom: 10px; font-size: 14px; }
.sense-pinyin { font-style: italic; color: #6A6580; }
.sense-def { line-height: 1.45; }
.sense-trad { font-size: 12px; color: var(--muted); }

.deep-senses { margin: 0; padding-left: 20px; }
.deep-senses li { margin-bottom: 10px; font-size: 14px; }
.ds-pinyin { font-style: italic; margin-right: 6px; }
.ds-pos {
  font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em;
  color: var(--muted); border: 1px solid var(--muted);
  border-radius: 4px; padding: 1px 5px;
}
.ds-gloss { margin-top: 3px; }
.ds-note { margin-top: 3px; font-size: 13px; color: #6A6580; line-height: 1.45; }

.example { margin-bottom: 12px; }
.ex-hanzi { font-size: 17px; }
.ex-pinyin { font-size: 13px; font-style: italic; color: #6A6580; }
.ex-english { font-size: 13px; color: var(--muted); }

.char-rows { display: grid; gap: 8px; }
.char-row {
  display: grid; grid-template-columns: 32px 68px 1fr;
  gap: 8px; align-items: baseline; font-size: 13px;
}
.cr-char { font-size: 22px; }
.cr-pinyin { font-style: italic; color: #6A6580; }
.cr-meaning { line-height: 1.4; }

/* The generated half can be absent, late or off. "Writing a fuller entry" reads
   as a wait, which is what it is. A failure says nothing at all — see the note
   in renderStudyWord: an upstream error is not the reader's to act on, and the
   CC-CEDICT senses above it are already a complete entry. */
.study-status { font-size: 13px; margin: 12px 0; color: var(--muted); }
.study-status.working { color: var(--teal); }
.study-credit { font-size: 11px; color: var(--muted); margin-top: 18px; }
.study-credit a { color: var(--muted); }

/* ---- Signing in from Study ------------------------------------------------
   Pins are per-account, so signed out there is nothing to list. The panel asks
   for the account rather than reporting that there isn't one — arriving here
   means the reader already wants what an account gives them. */
.study-auth-lead {
  font-size: 14px; line-height: 1.5;
  color: var(--muted); margin: 0 0 16px;
}
.auth-switch {
  display: flex; margin-bottom: 14px;
  border: 1px solid rgba(35,33,50,0.2);
  border-radius: 8px; overflow: hidden;
}
.auth-switch button {
  flex: 1;
  background: transparent; color: var(--muted);
  border: 0; border-radius: 0;
  padding: 9px 10px; font-size: 13px;
}
.auth-switch button.on { background: var(--teal); color: #fff; }
.study-auth-form { display: flex; flex-direction: column; }
.study-auth-form label {
  font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em;
  color: var(--muted); margin-bottom: 4px;
}
.study-auth-form input {
  background: #fff;
  border: 1px solid rgba(35,33,50,0.2);
  border-radius: 8px; padding: 10px 12px; margin-bottom: 12px;
  color: var(--ink); font-family: inherit; font-size: 15px;
}
.study-auth-form input:focus {
  border-color: var(--teal);
  outline: 2px solid rgba(63,168,140,0.35); outline-offset: -1px;
}
.study-auth-alt { font-size: 13px; color: var(--muted); margin: 14px 0 0; }
.study-auth-alt a { color: #227A62; }
/* .error-banner is drawn for the dark app background; on the light sheet its
   pale red text is invisible. */
.study-auth .error-banner {
  background: rgba(180,69,60,0.1);
  border-color: #B4453C;
  color: #8E3229;
}

/* ---- Sign-in options, How-To and Profile ----------------------------------
   OAuth buttons sit ABOVE the email fields on both auth screens. A button that
   removes two fields is only useful if it's seen before those fields are
   filled in - underneath, it's an offer to redo work already done. */
.oauth-options { margin-top: 18px; }
.oauth-btn { width: 100%; margin-bottom: 8px; }
.oauth-note { font-size: 12px; color: var(--muted); margin: 6px 0 0; }
.oauth-divider {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 18px 0 4px;
  color: var(--muted);
  font-size: 12px;
}
.oauth-divider::before, .oauth-divider::after {
  content: '';
  flex: 1;
  height: 1px;
  background: rgba(255,255,255,0.12);
}

/* The success counterpart to .error-banner, which is drawn in the same place
   and needs to be as easy to spot. */
.ok-banner {
  background: rgba(63,168,140,0.15);
  border: 1px solid var(--teal);
  color: #9fe0cd;
  padding: 10px 14px;
  border-radius: 8px;
  margin-bottom: 16px;
  font-size: 14px;
}

.profile-box { max-width: 420px; }
.profile-heading {
  font-size: 15px;
  margin: 28px 0 4px;
  padding-top: 20px;
  border-top: 1px solid rgba(255,255,255,0.1);
}
.profile-hint { font-size: 13px; color: var(--muted); margin: 0; }
.profile-nudge { font-size: 13px; }
.profile-methods { list-style: none; padding: 0; margin: 10px 0 0; }
.profile-methods li {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  padding: 9px 0;
  border-bottom: 1px solid rgba(255,255,255,0.07);
  font-size: 14px;
}
.profile-methods li:last-child { border-bottom: none; }
.profile-methods .method-state { color: var(--muted); font-size: 13px; }
.profile-methods li.on .method-state { color: var(--teal); }
.profile-connect { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 12px; }
.profile-connect button { width: auto; flex: 1 1 auto; margin-top: 4px; }

/* Numbered because the order is the point: this is the path through the app,
   not a feature list. */
.howto { max-width: 620px; margin: 0 auto; }
.howto-lede { color: var(--muted); font-size: 15px; margin-bottom: 24px; }
.howto-steps { list-style: none; counter-reset: howto; padding: 0; margin: 0; }
.howto-steps li {
  counter-increment: howto;
  position: relative;
  padding: 0 0 22px 42px;
}
.howto-steps li::before {
  content: counter(howto);
  position: absolute;
  left: 0;
  top: 0;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: rgba(63,168,140,0.16);
  border: 1px solid var(--teal);
  color: var(--teal);
  font-size: 13px;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
}
.howto-steps h3 { font-size: 15px; margin: 4px 0 5px; }
.howto-steps p { color: var(--muted); font-size: 14px; line-height: 1.6; margin: 0; }
.howto-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 8px; }
.howto-foot { color: var(--muted); font-size: 13px; margin-top: 16px; }

@media (max-width: 520px) {
  .howto-steps li { padding-left: 36px; }
  .howto-steps li::before { width: 24px; height: 24px; font-size: 12px; }
}

/* ---- Star: save to a playlist --------------------------------------------
   The same control on every song row, in Top Picks and My library alike, so
   "is this saved?" has one answer in one place. Filled and outlined are the
   same path at the same weight - a state change, not a different button.

   touch-action: manipulation kills the 300ms tap delay; the callout and select
   rules stop a press-and-hold raising the platform's own text menu over the
   picker it is opening. */
.row-actions { display: flex; align-items: center; gap: 2px; flex: 0 0 auto; }
/* The second control on a row that already carries artwork, a title, a level
   badge and a star. It earns its place by being quiet: smaller than the star,
   dimmer than the star, and the same width as the gap it replaced. */
.row-more {
  background: transparent;
  border: none;
  padding: 8px 4px;
  color: #6E6A80;
  display: flex;
  align-items: center;
  border-radius: 8px;
  cursor: pointer;
  touch-action: manipulation;
}
.row-more svg { fill: currentColor; }
.row-more:hover { color: #EFEDE6; }
.row-more:focus-visible { outline: 2px solid var(--teal); outline-offset: 1px; }

/* Feedback while a long press is being held. Without it a 500ms press with
   nothing happening reads as the app having missed the finger entirely. */
.song-card.pressing { background: rgba(255,255,255,0.10); }

/* ---- The row action sheet ----
   Built on .app-modal so it inherits the scrim and sits above everything, but
   anchored to the bottom on a phone where a centred dialog is a reach. */
.action-sheet { max-width: 420px; }
.action-sheet-head {
  display: flex;
  align-items: center;
  gap: 10px;
  padding-bottom: 12px;
  margin-bottom: 8px;
  border-bottom: 1px solid rgba(255,255,255,0.1);
}
.action-sheet-head .song-thumb { width: 42px; height: 42px; flex: 0 0 auto; }
.action-sheet-text { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.action-sheet-text strong { font-size: 15px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.action-sheet-text span { font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.action-sheet-items { display: flex; flex-direction: column; gap: 2px; }
@media (max-width: 600px) {
  #song-actions { align-items: flex-end; padding: 0; }
  .action-sheet {
    max-width: none;
    border-radius: 16px 16px 0 0;
    padding-bottom: calc(18px + env(safe-area-inset-bottom, 0px));
  }
}
.star-btn {
  background: transparent;
  border: none;
  padding: 8px;
  color: var(--muted);
  display: flex;
  align-items: center;
  cursor: pointer;
  border-radius: 8px;
  touch-action: manipulation;
  -webkit-touch-callout: none;
  user-select: none;
  transition: color 0.15s ease, transform 0.15s ease;
}
.star-btn:hover { color: #EFEDE6; }
.star-btn.on { color: var(--amber); }
.star-btn.on path { fill: currentColor; }
/* Feedback that the hold has been noticed, before the picker arrives. */
.star-btn.star-held { transform: scale(1.18); color: var(--amber); }
.star-btn:focus-visible { outline: 2px solid var(--teal); outline-offset: 1px; }

/* The remove × now sits in the same flex group as the star rather than pinned
   to the row's right edge, so the two can't overlap.

   A My library row carries the most of any list here - grip, art, title, level
   badge, star and × - so it buys back the width with a tighter gap than a Top
   Picks row needs. */
.my-song { padding-right: 8px; gap: 9px; }
/* It used to be pinned to the row's right edge. The offsets that did that have
   to be unset explicitly, not just the position: the hit-area rule below makes
   it relative again, and a leftover `right: 10px` shifts a relative element
   instead of being ignored - which walks it 10px back into the star. */
.row-remove {
  transform: none;
  top: auto;
  right: auto;
  /* Taller, not wider: the row has 86px of height going spare and none of it
     costs the title a character. */
  padding: 8px;
}

/* Both controls draw small so the row isn't crowded, and both are pressed with
   a thumb. The pseudo-element carries the 44px target without occupying any
   layout width, so meeting the touch floor costs the title nothing.

   8px of gap and 4px of reach each is what keeps the two targets flush but
   never overlapping - a star that eats part of the × is worse than either
   being small. */
.star-btn, .row-remove { position: relative; }
.star-btn::after, .row-remove::after {
  content: '';
  position: absolute;
  top: -5px;
  bottom: -5px;
  left: -4px;
  right: -4px;
}

/* ---- Drag to reorder ----
   touch-action: none on the grip is what lets a vertical drag start there
   instead of being claimed by the page's scroll - the rest of the row keeps
   its scroll gesture, which is why the grip exists rather than dragging the
   whole row. */
.drag-handle {
  position: relative;
  flex: 0 0 auto;
  color: var(--muted);
  font-size: 17px;
  line-height: 1;
  padding: 10px 4px;
  margin-left: -4px;
  cursor: grab;
  touch-action: none;
  user-select: none;
  -webkit-touch-callout: none;
}
/* Same trick as the star: a thumb needs more than the glyph is worth drawing,
   and reaching into the row's padding costs the title nothing. */
.drag-handle::after {
  content: '';
  position: absolute;
  top: -4px;
  bottom: -4px;
  left: -7px;
  right: -4px;
}
.song-card.dragging {
  position: relative;
  z-index: 5;
  cursor: grabbing;
  background: rgba(255,255,255,0.12);
  box-shadow: 0 8px 22px rgba(0,0,0,0.45);
  opacity: 0.96;
}
.song-card.dragging .drag-handle { cursor: grabbing; }
/* While a drag is live, nothing else on the page should be selectable or
   scroll-chaining underneath it. */
body.reordering { user-select: none; overscroll-behavior: contain; }
.reorder-hint { margin: 0 0 10px; }

/* ---- Playlist chips (the All view) ----
   Where a song lives, shown only when no single playlist is on screen. */
.pl-chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 5px; }
.pl-chip {
  font-size: 10px;
  line-height: 1;
  padding: 4px 7px;
  border-radius: 999px;
  color: var(--muted);
  background: rgba(255,255,255,0.07);
  border: 1px solid rgba(255,255,255,0.1);
  max-width: 120px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.all-note { margin: 0 0 14px; }

/* ---- Toast ----
   Sits above the nav bar's 40 and clear of the bottom on a phone, where the
   browser chrome eats the last few pixels. Never blocks the list: pointer
   events land on it only where there is something to press. */
#toast {
  position: fixed;
  left: 50%;
  bottom: calc(18px + env(safe-area-inset-bottom, 0px));
  transform: translate(-50%, 20px);
  z-index: 120;
  display: flex;
  align-items: center;
  gap: 14px;
  max-width: min(440px, calc(100vw - 32px));
  padding: 12px 14px;
  border-radius: 10px;
  background: #2C2A3C;
  border: 1px solid rgba(255,255,255,0.14);
  box-shadow: 0 10px 30px rgba(0,0,0,0.5);
  color: #EFEDE6;
  font-size: 14px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.18s ease, transform 0.18s ease;
}
#toast.show { opacity: 1; transform: translate(-50%, 0); pointer-events: auto; }
.toast-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.toast-action {
  flex: 0 0 auto;
  background: transparent;
  border: none;
  color: var(--teal);
  font-weight: 700;
  font-size: 14px;
  padding: 2px 2px;
}

/* ---- In-app dialogs ----
   Replacing confirm() and prompt(), which are modal to the whole browser, can't
   be styled, and on a phone announce themselves with the domain name. */
.app-modal {
  position: fixed;
  inset: 0;
  z-index: 130;
  background: rgba(12,12,20,0.68);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
}
.app-modal-card {
  width: 100%;
  max-width: 380px;
  background: #2C2A3C;
  border: 1px solid rgba(255,255,255,0.12);
  border-radius: 14px;
  padding: 18px;
  box-shadow: 0 18px 50px rgba(0,0,0,0.55);
}
.app-modal-card h3 { margin: 0 0 8px; font-size: 17px; }
.app-modal-card p { margin: 0 0 12px; font-size: 14px; color: var(--muted); line-height: 1.5; }
.modal-input {
  width: 100%;
  background: rgba(255,255,255,0.07);
  border: 1px solid rgba(255,255,255,0.18);
  color: #EFEDE6;
  border-radius: 8px;
  padding: 10px 12px;
  font-family: inherit;
  font-size: 15px;
}
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.modal-actions button { padding: 9px 16px; font-size: 14px; }
.modal-actions button.danger { background: #C0453F; }

/* ---- The playlist picker ----
   A checklist of the present tense - which playlists this song is in - rather
   than a menu of "add to…" actions, so taking a song out is the same gesture
   as putting it in. Each tick applies on the spot; there is no Save to forget.
   Capped in height so five playlists plus the new-playlist row can never push
   Done off a short screen. */
.picker-card { max-width: 400px; }
.picker-song {
  margin: 0 0 12px !important;
  color: #EFEDE6 !important;
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.picker-note { color: var(--amber) !important; font-size: 13px !important; }
.pick-list { max-height: 46vh; overflow-y: auto; margin-bottom: 4px; }
.pick-row {
  display: flex;
  align-items: center;
  gap: 11px;
  padding: 11px 10px;
  border-radius: 9px;
  cursor: pointer;
}
.pick-row:hover { background: rgba(255,255,255,0.06); }
.pick-row input { width: 18px; height: 18px; accent-color: var(--teal); flex: 0 0 auto; }
.pick-row.full { opacity: 0.5; cursor: default; }
.pick-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 15px; }
.pick-count { flex: 0 0 auto; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
.picker-new { margin-top: 10px; }
.picker-new button { flex: 0 0 auto; padding: 9px 12px; font-size: 13px; }

/* ---- Browse switch: Top Picks vs Shared playlists -------------------------
   Deliberately not styled like the section tabs above it. Those answer "which
   part of the app am I in"; this answers "which shelf am I looking at" inside
   one of them, and making them look alike would flatten that difference into
   six equal-looking tabs. */
.browse-switch {
  display: flex;
  gap: 4px;
  padding: 4px;
  margin-bottom: 14px;
  background: rgba(255,255,255,0.05);
  border-radius: 10px;
}
.browse-tab {
  flex: 1;
  background: transparent;
  color: var(--muted);
  border-radius: 7px;
  padding: 9px 10px;
  font-size: 14px;
  font-weight: 600;
}
.browse-tab.active { background: rgba(255,255,255,0.10); color: #EFEDE6; }
.browse-tab:focus-visible { outline: 2px solid var(--teal); outline-offset: -2px; }

/* A shared playlist in the browse list. The mark stands in for artwork - a
   playlist has no single cover, and a grid of four thumbnails at this size
   reads as noise. */
.shared-mark {
  flex: 0 0 auto;
  width: 48px;
  height: 48px;
  border-radius: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(63,168,140,0.16);
  border: 1px solid rgba(63,168,140,0.4);
  color: var(--teal);
  font-size: 20px;
}
.shared-go { color: var(--muted); font-size: 22px; flex: 0 0 auto; padding-left: 4px; }
.shared-head { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.shared-head h2 { margin: 0; font-size: 19px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* ---- Share toggle in My library ----
   Given a whole row rather than tucked next to Rename/Delete: it is the only
   control on the screen whose effect other people can see, and it should not
   be one tap away from a button that just renames something. */
.share-row {
  display: flex;
  flex-direction: column;
  gap: 4px;
  padding: 10px 12px;
  margin-bottom: 12px;
  border-radius: 10px;
  background: rgba(255,255,255,0.04);
  border-left: 3px solid transparent;
}
.share-row.is-shared { border-left-color: var(--teal); background: rgba(63,168,140,0.09); }
.share-toggle { display: flex; align-items: center; gap: 9px; cursor: pointer; font-size: 14px; font-weight: 600; }
.share-toggle input { width: 18px; height: 18px; accent-color: var(--teal); flex: 0 0 auto; }
.share-note { font-size: 11px; color: var(--muted); line-height: 1.5; }

/* The wrapping .video-bar and its .icon-transport glyph buttons were retired
   when the controls moved into the pinned .transport at the bottom of this
   file. The bar had to wrap to two rows to fit six controls at 375px; the
   controls are now split between the now-playing row, the level row and the
   transport, so none of them is crowded. */

/* ---- Queue sheet ----
   Same sheet chrome as Study, and for the same reason: a re-render of #view
   would tear down the YouTube iframe and restart the song, so this is a panel
   over the player rather than a view of its own. */
.queue-panel { max-height: 78vh; }
/* Sits between the title and the close, on the sheet's light surface, so it
   reads as an action on this list rather than as a control of the player. */
.queue-save {
  margin-left: auto;
  background: transparent;
  border: 1px solid rgba(0,0,0,0.22);
  color: #5C5870;
  font-size: 12px;
  font-weight: 700;
  padding: 6px 11px;
  border-radius: 999px;
}
.queue-save:hover { background: rgba(0,0,0,0.06); color: var(--ink); }
/* The close button claims the right edge with margin-left: auto of its own; with
   Save now taking it, the close needs none. */
.queue-panel .study-close { margin-left: 4px; }
.queue-source { margin: 0 0 12px; font-size: 13px; color: #5C5870; }
.queue-source strong { color: var(--ink); }
/* One scroller for the whole queue, and since v47 one list inside it. The sheet
   used to be three blocks - Already played / Now playing / Next up - and only
   the last of them could be dragged, so a song you had just heard could not be
   pulled back into the future without leaving the sheet to find it by name.
   renderQueuePanel scrolls this to the current song on open. */
.queue-scroll { overflow-y: auto; flex: 1; min-height: 0; }
.queue-list { min-height: 0; }
.queue-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 7px 6px;
  border-radius: 9px;
}
/* The row is drawn on the light sheet, so it needs its own text colours rather
   than the dark-background ones .song-card-text carries. */
.queue-row .song-card-text strong { color: var(--ink); font-size: 15px; }
.queue-row .song-card-text span { color: #6E6A80; }
.queue-row .song-thumb { width: 40px; height: 40px; flex: 0 0 auto; }
.queue-tap { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; cursor: pointer; }
.queue-row.is-current { background: rgba(63,168,140,0.14); }
.queue-row.is-current .song-card-text strong { color: #17604B; }
/* Behind you. Dimmed rather than hidden or moved: it is a trail back to the
   song you want to hear again, and it should read as past at a glance. Full
   contrast on hover, because it is just as tappable and just as draggable as
   anything below it. Set per row rather than on a wrapper, so a drag across the
   current song can change what is past without rebuilding the list. */
.queue-row.is-past { opacity: 0.6; }
.queue-row.is-past:hover { opacity: 1; }
/* Replaces the "Now playing" heading the old three-section sheet carried. The
   label has to travel with the row now that the row can be anywhere in it. */
.queue-now-tag {
  flex: 0 0 auto;
  font-size: 10px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  font-weight: 700;
  color: #17604B;
  background: rgba(63,168,140,0.22);
  border-radius: 999px;
  padding: 3px 8px;
}
.queue-row .drag-handle { color: #8B889C; }
.queue-row .row-remove { color: #8B889C; font-size: 20px; }
.queue-row .row-remove:hover { color: #C0453F; }
/* The star is drawn for a dark row everywhere else in the app; the queue sheet
   is the one light surface it appears on, so its off state needs a colour that
   is actually visible against cream. The filled state is the same amber. */
.queue-row .star-btn { padding: 6px; color: #8B889C; }
.queue-row .star-btn:hover { color: var(--ink); }
.queue-row .star-btn.on { color: #C98416; }
.queue-row.dragging { background: #E7E2D6; box-shadow: 0 6px 18px rgba(0,0,0,0.25); }
.queue-empty,
.queue-note { font-size: 12px; color: #6E6A80; margin: 8px 0 0; line-height: 1.5; }
.queue-note { border-top: 1px solid rgba(0,0,0,0.09); padding-top: 10px; }

/* Spotify sync status. Sits under the "Open in Spotify" button and says which
   of the three waiting states you are in — press play, wrong track, or the
   connection is down. All three used to be silent, and silence made a broken
   token look identical to a song you simply hadn't started. */
.spotify-status {
  margin: 10px 0 0;
  font-size: 13px;
  line-height: 1.5;
  color: var(--muted);
}
.spotify-status.is-error { color: var(--amber); }
.spotify-status[hidden] { display: none; }

/* ===========================================================================
   Pinned transport
   ===========================================================================
   The controls leave the flow and anchor to the bottom; the video stays where
   it was. Everything else that lives against the bottom edge - the word card,
   the Study and Queue sheets - is lifted by exactly this height so the
   transport is never covered. Playback stays reachable while a definition is
   open, which matters here because tapping a word already pauses the song and
   you previously had to close the card to start it again.

   Keyed off `body:has(...)` rather than a JS class, so there is no flag left to
   go stale. What it asks changed in v49: the transport no longer lives inside
   #view and no longer disappears on navigation, so the question is not "is
   there a transport" but "is the dock on screen", which is what the layer's own
   state attribute answers.

   --dock-h is measured in JS (syncDockOffset) rather than being a constant,
   because the dock is a different height in each state - the mini strip is only
   there while collapsed, and it grows a row with a long title on a narrow
   screen. */
:root { --dock-h: 0px; }

.transport {
  /* Static inside the dock. It was itself fixed until v49; now the dock is, and
     two fixed elements stacked on the same edge is one more than is needed. */
  position: relative;
  padding: 0 14px;
}
body:has(#player-layer:not([data-state="idle"])) main { padding-bottom: calc(var(--dock-h) + 20px); }
body:has(#player-layer:not([data-state="idle"])) #word-card,
body:has(#player-layer:not([data-state="idle"])) .study-panel { bottom: var(--dock-h); }
body:has(#player-layer:not([data-state="idle"])) #toast { bottom: calc(var(--dock-h) + 14px); }
/* The card's own reserve has to clear the dock too, or the line you just tapped
   a word in hides behind the two of them together. */
body:has(#player-layer:not([data-state="idle"])) main:has(#word-card) {
  padding-bottom: calc(46vh + var(--dock-h));
}

/* ===========================================================================
   The persistent player
   ===========================================================================
   The panel is the now-playing screen and the dock is the strip that is always
   there. Both outlive a navigation; see the note in index.html.

   The panel starts BELOW the section tabs on purpose. Those tabs are how you
   change screen, and covering them would mean collapsing the player first -
   a tap that today costs nothing. */
.player-panel {
  position: fixed;
  top: calc(var(--topbar-h, 56px) + var(--navbar-h, 0px));
  left: 0;
  right: 0;
  bottom: var(--dock-h);
  z-index: 30;
  overflow-y: auto;
  overscroll-behavior: contain;
  background: var(--bg);
  padding: 14px 20px 24px;
  transition: transform 0.28s cubic-bezier(0.22, 0.61, 0.36, 1);
}
/* Everything inside lines up with <main>'s column on a wide window rather than
   stretching across a desktop's full width. One wrapper rather than a rule on
   each child: several of them set `margin` as a shorthand, which would reset
   the auto side margins doing the centring. */
.player-column { max-width: 720px; margin: 0 auto; }

/* Collapsed and idle both push the panel off the bottom of the screen.
   Deliberately NOT display:none and NOT visibility:hidden: an iframe that is
   taken out of the rendering tree can have its playback suspended by the
   browser, and the audio is the entire point of keeping it alive. Off-screen
   and inert is the strongest hiding that is safe here. */
.player-layer[data-state="collapsed"] .player-panel,
.player-layer[data-state="idle"] .player-panel {
  transform: translateY(calc(100% + var(--dock-h) + 40px));
  pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
  .player-panel { transition: none; }
}

.player-head { display: flex; align-items: center; gap: 10px; margin: 0 0 14px; }
.player-head .song-heading { flex: 1; min-width: 0; margin: 0; }
.player-head .video-thumb { flex: 0 0 auto; width: 44px; height: 44px; margin: 0; }
.player-head .video-thumb[hidden] { display: none; }
.player-collapse {
  flex: 0 0 auto;
  width: 38px; height: 38px;
  border-radius: 50%;
  background: transparent;
  border: none;
  padding: 0;
  color: var(--muted);
  display: flex; align-items: center; justify-content: center;
  cursor: pointer;
}
.player-collapse:hover { background: rgba(255,255,255,0.08); color: #EFEDE6; }
.player-collapse svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 2; }

/* ---- The dock ---- */
.player-dock {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  z-index: 70;
  background: #221F30;
  border-top: 1px solid rgba(255,255,255,0.10);
  box-shadow: 0 -6px 24px rgba(0,0,0,0.42);
  /* The 10px the transport used to carry itself, plus whatever the phone's home
     indicator wants. It moved out here because the dock is what touches the
     bottom edge now. */
  padding-bottom: calc(10px + env(safe-area-inset-bottom, 0px));
}
.player-layer[data-state="idle"] .player-dock { display: none; }

/* The mini strip. Only drawn while collapsed - with the now-playing screen up,
   the artwork and the title are already on it at twice the size. */
.mini-player {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 8px 8px 0 14px;
}
.mini-open {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 10px;
  background: transparent;
  border: none;
  padding: 4px;
  border-radius: 10px;
  color: inherit;
  font-family: inherit;
  text-align: left;
  cursor: pointer;
}
.mini-open:hover { background: rgba(255,255,255,0.06); }
.mini-open .song-thumb { width: 38px; height: 38px; flex: 0 0 auto; }
.mini-text { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.mini-text strong,
.mini-text span {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  display: block;
}
.mini-text strong { font-size: 14px; }
.mini-text span { font-size: 12px; color: var(--muted); }
.mini-close {
  flex: 0 0 auto;
  background: transparent;
  border: none;
  color: var(--muted);
  font-size: 22px;
  line-height: 1;
  padding: 6px 10px;
  cursor: pointer;
}
.mini-close:hover { color: #EFEDE6; }

/* ---- Progress / seek ----
   Full-bleed across the top edge of the bar. The hit area is far taller than
   the line is drawn, because a 4px target is unusable with a thumb. */
.transport-progress {
  position: relative;
  height: 4px;
  margin: 0 -14px 8px;
  background: rgba(255,255,255,0.14);
  cursor: pointer;
  touch-action: none;
}
.transport-progress[role="slider"]::before {
  content: '';
  position: absolute;
  top: -11px;
  bottom: -11px;
  left: 0;
  right: 0;
}
.transport-progress[role="presentation"] { cursor: default; }
.transport-elapsed {
  height: 100%;
  width: 0;
  background: var(--teal);
  transition: width 0.25s linear;
}
.transport-knob {
  position: absolute;
  top: 50%;
  left: 0;
  width: 11px;
  height: 11px;
  margin: -5.5px 0 0 -5.5px;
  border-radius: 50%;
  background: var(--teal);
  box-shadow: 0 0 0 3px rgba(63,168,140,0.25);
  pointer-events: none;
}
.transport-progress:focus-visible { outline: 2px solid var(--teal); outline-offset: 4px; }

.transport-row { display: flex; align-items: center; gap: 10px; }
/* The clock and the right-hand button group are equal-width bookends, so the
   transport group sits optically centred rather than being shoved off-centre by
   whichever side happens to be wider - which since v47 is the right, holding
   both repeat and lyrics against the clock's one line of text.

   flex: 1 1 0 on both is what makes that true regardless of content: the clock
   reads "0:00" until the video reports its duration and "1:23 / 4:05"
   afterwards, and a bookend sized to its content would shunt the whole
   transport sideways a second or two into every song. */
.transport-time {
  flex: 1 1 0;
  min-width: 0;
  overflow: hidden;
  font-size: 12px;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.transport-main { display: flex; align-items: center; gap: 14px; flex: 0 0 auto; }
/* The stretching happens on this wrapper, never on the button inside it. When
   #lyrics-btn was itself the flex child, `flex: 1 1 0` grew a 44px circle into a
   ~150px-wide box that border-radius:50% drew as an oval - and since the icon
   was pushed to its right edge by justify-content, the :hover fill painted a
   grey pill stretching away to the LEFT of the icon, over empty bar. Tapping it
   on a phone left that pill stuck on screen, because a tapped button keeps
   :hover until something else is touched. */
.transport-end { flex: 1 1 0; display: flex; justify-content: flex-end; gap: 6px; }
/* Never let the two buttons in here be shrunk to make room. They are circles;
   a shrunk circle is an oval, and the :hover fill draws that oval over empty
   bar - which is the exact failure the note above this block describes from the
   last time something in the transport was allowed to stretch. Squeezing the
   clock instead is free, because it can hide its total. */
.transport-end > button { flex: 0 0 auto; }

/* ---- Transport buttons ----
   Play is a filled teal disc and everything else is a plain outline icon: the
   control you reach for most should be identifiable without reading, and the
   others should not compete with it. */
.icon-btn-round,
.play-btn,
.round-btn {
  background: transparent;
  border: none;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  color: #EFEDE6;
  -webkit-tap-highlight-color: transparent;
}
.icon-btn-round { width: 44px; height: 44px; border-radius: 50%; }
.icon-btn-round:hover { background: rgba(255,255,255,0.09); }
/* A transport button that is a toggle rather than an action. Colour carries the
   on state, and the label under it carries the rest - repeat has three states
   and no amount of tinting distinguishes "the queue" from "this song", which is
   what the 1 in the icon and the title text are for. */
.icon-btn-round.on { color: var(--teal); background: rgba(63,168,140,0.16); }
.icon-btn-round.on:hover { background: rgba(63,168,140,0.26); }
.icon-btn-round:disabled { opacity: 0.3; cursor: default; }
.icon-btn-round:disabled:hover { background: transparent; }
.play-btn {
  width: 54px;
  height: 54px;
  border-radius: 50%;
  background: var(--teal);
  color: #fff;
  box-shadow: 0 3px 12px rgba(63,168,140,0.4);
}
.play-btn:hover { filter: brightness(1.08); }
.play-btn:active { transform: scale(0.95); }
.icon-btn-round svg, .round-btn svg { width: 22px; height: 22px; }
.play-btn svg { width: 26px; height: 26px; }
/* One rule themes every icon: the paths carry no fill or stroke of their own
   and inherit from the button. Closed shapes fill; the open paths that draw
   list rules are stroked instead. */
.icon-btn-round svg, .play-btn svg, .round-btn svg { fill: currentColor; }
.icon-btn-round svg path[stroke-linecap],
.round-btn svg path[stroke-linecap] { fill: none; stroke: currentColor; stroke-width: 1.9; }
.icon-btn-round:focus-visible,
.play-btn:focus-visible,
.round-btn:focus-visible { outline: 2px solid var(--teal); outline-offset: 2px; }

/* ---- Now-playing row ----
   Artwork, title and the queue on one line. Collapsing the video used to leave
   a second strip of controls under the first; this is that strip folded into
   the row that was already there. */
.now-playing { display: flex; align-items: center; gap: 12px; margin: 4px 0 14px; }
.now-playing .song-heading { flex: 1; min-width: 0; margin: 0; }
.now-playing .video-thumb { flex: 0 0 auto; width: 46px; height: 46px; margin: 0; }
.now-playing .video-thumb[hidden] { display: none; }
.round-btn {
  position: relative;
  flex: 0 0 auto;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: rgba(255,255,255,0.07);
  border: 1px solid rgba(255,255,255,0.13);
}
.round-btn:hover { background: rgba(255,255,255,0.13); }
.round-btn-count {
  position: absolute;
  top: -3px;
  right: -3px;
  min-width: 18px;
  height: 18px;
  padding: 0 4px;
  border-radius: 9px;
  background: var(--teal);
  color: #fff;
  font-size: 10px;
  font-weight: 700;
  line-height: 18px;
  text-align: center;
}
.round-btn-count[hidden] { display: none; }
.video-toggle { margin-left: auto; flex: 0 0 auto; }

/* ---- Full lyrics overlay ----
   Near-full-screen: a whole song of Chinese, pinyin and English needs the room,
   and a 78vh sheet would spend most of its height on chrome. Sits above the
   page but below the word card, so tapping a word in here still works and the
   definition lands on top of it. */
.lyrics-overlay {
  position: fixed;
  top: 5vh;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 58;
  display: flex;
  flex-direction: column;
  background: var(--surface);
  color: var(--ink);
  border-radius: 16px 16px 0 0;
  box-shadow: 0 -6px 30px rgba(0,0,0,0.5);
  overflow: hidden;
}
body:has(#player-layer:not([data-state="idle"])) .lyrics-overlay { bottom: var(--dock-h); }
.lyrics-overlay-head {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 14px 18px 10px;
  border-bottom: 1px solid rgba(0,0,0,0.09);
}
.lyrics-overlay-title { flex: 1; min-width: 0; }
.lyrics-overlay-title strong {
  display: block;
  font-size: 17px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.lyrics-overlay-title span { font-size: 13px; color: #6E6A80; }
.lyrics-overlay-head .study-close { position: static; color: var(--ink); }
.lyrics-overlay-body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: 6px 18px 40vh;
  -webkit-overflow-scrolling: touch;
  /* The hanzi in here carry no size of their own - they inherit. Stating the
     inherited size in rem keeps "normal" at exactly the 16px it has always
     been, while giving --lyric-scale something to multiply. */
  font-size: calc(1rem * var(--lyric-scale));
}

/* Every line carries pinyin and, where there is one, the English. Unsung lines
   are dimmed rather than hidden, so the shape of the song stays readable. */
.ov-line {
  padding: 9px 10px;
  margin: 0 -10px;
  border-radius: 10px;
  opacity: 0.55;
  transition: opacity 0.2s ease, background 0.2s ease;
}
.ov-line.is-current { opacity: 1; background: rgba(63,168,140,0.13); }
.lyrics-overlay-body .ov-hz .word { color: var(--ink); }
.lyrics-overlay-body .ov-hz .py { color: #6E6A80; }
.ov-en { font-size: calc(13px * var(--lyric-scale)); color: #5C5870; margin-top: 5px; }
.ov-empty { color: #6E6A80; font-size: 14px; }
/* A song with no timestamps gets no highlight, so nothing is dimmed either -
   there is no current line for the rest to contrast against. */
.lyrics-overlay-body:not(:has(.is-current)) .ov-line { opacity: 1; }

.ov-follow {
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  bottom: 16px;
  padding: 9px 16px;
  border-radius: 999px;
  font-size: 13px;
  box-shadow: 0 4px 14px rgba(0,0,0,0.3);
}
.ov-follow[hidden] { display: none; }

/* The narrow-phone transport. It lives down here rather than in the 600px
   block near the top of this file because every rule it overrides is declared
   below that block - at equal specificity the later declaration wins, so a
   transport tweak written up there is simply dead. (Found the hard way: the
   400px .play-btn override applied and the .icon-btn-round one next to it did
   not.)

   Five controls plus a clock on a 375px bar. Everything gives back a few pixels
   rather than one of them being dropped: a transport that changes shape between
   a phone and a laptop is harder to learn than a slightly tighter one. */
@media (max-width: 420px) {
  /* Elapsed only. The total is the least useful number on the bar - you can see
     how far along the song is from the progress line directly above it - and
     dropping it buys back the ~45px that five controls need at this width. */
  .transport-total { display: none; }
  .transport-row { gap: 6px; }
  .transport-main { gap: 10px; }
  .transport-end { gap: 2px; }
  .icon-btn-round { width: 40px; height: 40px; }
  .play-btn { width: 50px; height: 50px; }
}
