/* Palette and layout language taken from Claude Design import:
   "Concept 3 - Media Collection Dashboard" (see CLAUDE.md, "Дизайн"). */

:root {
  --bg-page: #0e0d14;
  --bg-panel: #12111a;
  --bg-surface: #1a1922;
  --border: #2c2b38;
  --text-primary: #e6e5ee;
  --text-secondary: #c7c6d6;
  --text-muted: #6e6c85;
  --accent: #6d28d9;
  --accent-teal: #22d3c5;
  --accent-green: #2fd07a;
  --accent-orange: #f0a63f;
  --font-sans:
    -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
  --font-mono: ui-monospace, Menlo, monospace;
  --font-serif: Georgia, "Times New Roman", serif;
  /* Reader typography/width settings (PR 30) - overridden inline on :root by
     reader-settings.js from the visitor's saved preference (localStorage), read fresh on
     every chapter page load. These defaults match the reader's pre-PR-30 hardcoded values. */
  --reader-font-family: var(--font-sans);
  --reader-font-size: 15px;
  --reader-line-height: 1.85;
  --reader-width: 640px;
  /* PR 166: same pattern as the reader typography vars above, but independent of them -
     a bigger reading font shouldn't force a bigger comment font and vice versa. Default
     matches the comment body's old hardcoded 14px (PR 158). */
  --comment-font-size: 14px;
  /* Mobile account strip's own height (PR 71) - shared by .sidebar__account's min-height
     and .main's padding-top below it, so the two can't drift apart into two independent
     hardcoded numbers the way they did before (see the .sidebar__link fix in the same
     PR for the actual cause of that drift). */
  --mobile-account-height: 48px;
}

* {
  box-sizing: border-box;
}

/* Custom scrollbar (PR 55) - both APIs, since neither alone covers every browser.
   scrollbar-color is inherited, so setting it on :root reaches every scrollable
   element (page, sidebar, dropdown listboxes) without repeating it per container. */
:root {
  scrollbar-color: var(--border) transparent;
  scrollbar-width: thin;
}

*::-webkit-scrollbar {
  width: 10px;
  height: 10px;
}

*::-webkit-scrollbar-track {
  background: transparent;
}

*::-webkit-scrollbar-thumb {
  background-color: var(--border);
  border-radius: 999px;
  border: 2px solid transparent;
  background-clip: padding-box;
}

*::-webkit-scrollbar-thumb:hover {
  background-color: var(--accent);
}

*::-webkit-scrollbar-corner {
  background: transparent;
}

body {
  margin: 0;
  background: var(--bg-page);
  color: var(--text-primary);
  font-family: var(--font-sans);
}

a {
  color: inherit;
}

.app-shell {
  display: flex;
  min-height: 100vh;
}

.sidebar {
  width: 64px;
  flex: 0 0 64px;
  align-self: flex-start;
  position: sticky;
  top: 0;
  height: 100vh;
  overflow-x: hidden;
  overflow-y: auto;
  /* PR 116: overflow-y: auto above is still needed for the rare case the icon list
     genuinely outgrows the viewport, but on ordinary short/medium windows it leaves just
     enough overhang to paint a visible scrollbar in this 64px-wide icon-only column,
     which has no room to spare for one. Hide the scrollbar itself (both engines' APIs)
     rather than removing overflow-y - scrolling still works via keyboard/wheel/touch,
     same trade-off *::-webkit-scrollbar's global thin styling elsewhere in this file
     makes, just all the way to invisible instead of just thinner. */
  scrollbar-width: none;
  background: var(--bg-panel);
  border-right: 1px solid var(--border);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 22px;
  padding: 20px 0;
  transition: width 0.2s ease, flex-basis 0.2s ease;
}

.sidebar::-webkit-scrollbar {
  display: none;
}

/* PR 175: the notification bell (base.html, PR 168) is a native <button>, the only
   .sidebar__link that isn't an <a> - everything below this point resets properties an <a>
   already has no UA styling for anyway (no background/border/own font/padding), so it's a
   no-op there and only actually changes anything for the button, bringing it visually in
   line with the rest of the sidebar in every layout instead of just patching the mobile
   tab-bar where the mismatch happened to be most visible. */
.sidebar__link {
  position: relative;
  display: flex;
  align-items: center;
  gap: 12px;
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  font-size: 11px;
  color: var(--text-muted);
  text-decoration: none;
  text-align: center;
  border-radius: 8px;
  cursor: pointer;
  transition: color 0.15s ease;
}

/* Active-downloads count (PR 56) - anchored to the icon's corner in the collapsed
   sidebar (phone-style notification dot), inline after the label once expanded. */
.sidebar__badge {
  position: absolute;
  top: 2px;
  right: 2px;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  border-radius: 999px;
  background: var(--accent-orange);
  color: var(--bg-page);
  font-size: 10px;
  font-weight: 600;
  line-height: 16px;
  text-align: center;
}

/* Direct nav links (icon-only in the collapsed state) get a fixed square hit-box with
   the icon centered inside - unlike the account/login links below them, which stay
   auto-sized to their text and shouldn't be squeezed into 44x44. */
.sidebar > .sidebar__link {
  justify-content: center;
  gap: 0;
  width: 44px;
  height: 44px;
}

.sidebar__label {
  display: inline-block;
  max-width: 0;
  overflow: hidden;
  opacity: 0;
  font-size: 13px;
  white-space: nowrap;
  transition: max-width 0.2s ease, opacity 0.15s ease;
}

@media (min-width: 641px) {
  .sidebar--expanded {
    width: 200px;
    flex: 0 0 200px;
    align-items: stretch;
  }

  .sidebar--expanded .sidebar__link {
    width: auto;
    height: auto;
    justify-content: flex-start;
    gap: 12px;
    padding: 10px 22px;
  }

  .sidebar--expanded .sidebar__label {
    max-width: 140px;
    opacity: 1;
  }

  .sidebar--expanded .sidebar__badge {
    position: static;
    margin-left: auto;
  }
}

.sidebar__toggle {
  background: none;
  border: none;
  padding: 0;
  color: var(--text-muted);
  cursor: pointer;
  transition: color 0.15s ease;
}

.sidebar__link svg {
  vertical-align: middle;
  transition: transform 0.15s ease;
}

/* Hover-only devices (mouse/trackpad) - gated so a tap on a touchscreen doesn't leave
   the color/scale change "stuck" until the visitor taps elsewhere (touch UAs otherwise
   apply :hover on tap and only clear it on the next unrelated tap). */
@media (hover: hover) {
  .sidebar__link:hover {
    color: var(--accent-teal);
  }

  .sidebar__link:hover svg {
    transform: scale(1.12);
  }

  .sidebar__toggle:hover {
    color: var(--accent-teal);
  }
}

@keyframes sidebar-active-highlight {
  from {
    background-color: rgba(34, 211, 197, 0);
  }
  to {
    background-color: rgba(34, 211, 197, 0.12);
  }
}

.sidebar__link--active {
  color: var(--accent-teal);
  background-color: rgba(34, 211, 197, 0.12);
  animation: sidebar-active-highlight 0.3s ease;
}

.sidebar__link--disabled {
  opacity: 0.6;
  cursor: default;
}

.sidebar__account {
  margin-top: auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding-top: 12px;
  border-top: 1px solid var(--border);
  width: 100%;
}

/* PR 93: now a <button> (was a plain <a> to /settings) - it opens the profile-menu
   dropdown below instead of linking directly, so it needs the usual button resets a link
   never did (border/padding/font-family/cursor). */
.sidebar__avatar {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border: none;
  padding: 0;
  border-radius: 50%;
  background: var(--accent);
  color: var(--text-primary);
  font-family: inherit;
  font-size: 13px;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
}

/* PR 96: an uploaded avatar image, shown instead of the initials text - shared by
   .sidebar__avatar and .profile__avatar, both of which are already fixed-size flex
   containers with border-radius: 50%, so the image just needs to fill that box. */
.avatar-img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: inherit;
}

/* PR 126: no :hover recolor here on purpose - the avatar's clickability is already
   obvious (cursor: pointer above, plus the dropdown it opens), so repainting the
   initials/photo on hover read as noise rather than a useful affordance. */

/* PR 93: the avatar's dropdown menu (Профиль/Читаю/Настройки/Выйти). */
.profile-menu {
  position: relative;
}

/* PR 97: position (top/bottom/left) is set inline by profile-menu.js from the trigger's
   own getBoundingClientRect() once the panel is portaled to <body> and made `position:
   fixed` - not handled here, since `.sidebar`'s `overflow-y: auto` (its own link list's
   scrolling) clips an absolutely/relatively-positioned descendant regardless of z-index.
   The open/closed state also toggles a class on the panel itself (`--open` below) rather
   than relying on a `.profile-menu--open .profile-menu__panel` descendant selector like
   before PR 97 - once portaled, the panel is a sibling of `.profile-menu` in the DOM, not
   its descendant, so that selector would silently stop matching and the menu would stay
   invisible (opacity/pointer-events never flip) even though it's now positioned exactly
   where it should be. `--above`/`--below` (toggled by that same script, based on which
   side of the trigger has more room - upward on the desktop sidebar, downward on the
   mobile top account strip) only control which direction the open/close slide animates
   from - same fade + slide-toward-the-trigger animation as the custom <select> dropdown
   (PR 54's .dropdown__listbox), always in the DOM (opacity/pointer-events, not
   display:none) so the transition has something to animate. */
/* PR 120: while closed, the panel is still parked in its home spot inside .sidebar (the
   JS below only portals it out to <body> on open) - `position: absolute` with no offsets
   set here falls back to its *static* position, right below the avatar button, which
   itself sits flush against .sidebar's bottom edge (.sidebar__account's margin-top:
   auto). That pushes the panel's box past .sidebar's own bottom edge even while fully
   invisible (opacity: 0 below), and .sidebar's overflow-y: auto (kept for the rare case
   the link list itself outgrows the viewport, PR 116) still counts that as scrollable
   overflow - scrolling the sidebar at all then visibly shifts the real icons up by that
   same phantom amount, the "sidebar scrolls" bug. Collapse the box to nothing while it's
   parked here - max-height alone isn't enough with box-sizing: border-box, since the used
   height can't shrink below the border+padding sum, so those are zeroed too. profile-
   menu.js always portals the panel to <body> (out from under this selector) and sets
   explicit position: fixed coordinates before ever making it visible, so none of this
   affects the actual open panel. */
.sidebar .profile-menu__panel {
  max-height: 0;
  padding: 0;
  border-width: 0;
  overflow: hidden;
}

.profile-menu__panel {
  display: flex;
  flex-direction: column;
  gap: 2px;
  position: absolute;
  z-index: 40;
  min-width: 180px;
  padding: 6px;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: 0 10px 24px rgba(0, 0, 0, 0.35);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s ease, transform 0.15s ease;
}

.profile-menu__panel--above {
  transform: translateY(6px);
}

.profile-menu__panel--below {
  transform: translateY(-6px);
}

.profile-menu__panel--open {
  opacity: 1;
  transform: translateY(0);
  pointer-events: auto;
}

/* The "Выйти" item is a <button> inside its own <form> (it POSTs to /logout, unlike the
   other three items' plain links) - display: contents drops the <form> from layout so
   that button still sits directly in .profile-menu__panel's flex column/gap like its
   siblings, instead of getting wrapped in an extra box. */
.profile-menu__form {
  display: contents;
}

.profile-menu__item {
  display: block;
  width: 100%;
  padding: 8px 10px;
  border: none;
  border-radius: 6px;
  background: none;
  color: var(--text-secondary);
  font-family: inherit;
  font-size: 13px;
  text-align: left;
  text-decoration: none;
  cursor: pointer;
  transition: background-color 0.1s ease, color 0.1s ease;
}

@media (hover: hover) {
  .profile-menu__item:hover {
    background: rgba(34, 211, 197, 0.12);
    color: var(--text-primary);
  }
}

/* PR 168: the sidebar bell's flyout - notifications-panel.js portals it to <body> on
   open, same position: fixed mechanics as .profile-menu__panel (PR 97) above, so it needs
   the same "collapse to nothing while parked inside .sidebar" treatment to avoid that same
   .sidebar overflow-y: auto scroll bug (see .sidebar .profile-menu__panel's own comment). */
.sidebar .notifications-panel {
  max-height: 0;
  padding: 0;
  border-width: 0;
  overflow: hidden;
}

/* Deliberately wider than .catalog-filters' 240px (PR 85) - a notification row needs
   room for an avatar plus a line or two of text plus a timestamp, where a filter
   checkbox only needs its own label - but still a small capped-width side panel, not a
   centered modal (.title-quickview-modal) or a full-bleed one. Opens flush against the
   sidebar's own right edge (notifications-panel.js's position()), not below/above the
   trigger like .profile-menu__panel - it's the sidebar's own flyout, not a dropdown tied
   to one icon's position. */
.notifications-panel {
  display: flex;
  flex-direction: column;
  position: absolute;
  z-index: 40;
  width: 340px;
  max-width: calc(100vw - 32px);
  max-height: min(480px, calc(100vh - 32px));
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: 0 10px 24px rgba(0, 0, 0, 0.35);
  opacity: 0;
  pointer-events: none;
  transform: translateX(-6px);
  transition: opacity 0.15s ease, transform 0.15s ease;
}

.notifications-panel--open {
  opacity: 1;
  pointer-events: auto;
  transform: translateX(0);
}

.notifications-panel__header {
  flex: 0 0 auto;
  padding: 12px 14px;
  border-bottom: 1px solid var(--border);
}

.notifications-panel__title {
  margin: 0;
  font-size: 14px;
  font-weight: 600;
  color: var(--text-primary);
}

.notifications-panel__list {
  display: flex;
  flex-direction: column;
  overflow-y: auto;
}

.notifications-panel__empty {
  margin: 0;
  padding: 20px 14px;
  color: var(--text-muted);
  font-size: 13px;
  text-align: center;
}

/* PR 169: links to notifications.html's full "Все уведомления" list. */
.notifications-panel__footer {
  flex: 0 0 auto;
  padding: 10px 14px;
  border-top: 1px solid var(--border);
  color: var(--text-muted);
  font-size: 12px;
  text-align: center;
  text-decoration: none;
  transition: color 0.1s ease;
}

@media (hover: hover) {
  .notifications-panel__footer:hover {
    color: var(--accent-teal);
  }
}

/* PR 170: the outer row (link + actions) - was itself the link/flex-row for
   avatar+body before this PR; that's .notifications-panel__item-link now, since the
   mark-read/delete buttons need to sit outside the <a> (see _notification_card.html's
   own comment on why a <button> can't nest inside it). */
.notifications-panel__item {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 10px 14px;
  transition: background-color 0.1s ease;
}

@media (hover: hover) {
  .notifications-panel__item:hover {
    background: rgba(34, 211, 197, 0.08);
  }
}

/* Unread rows keep this tint even while hovered (the hover rule above only adds to it,
   doesn't replace it) - same purpose as the bell's own badge, a visible "you haven't
   seen this yet" cue that survives a passing hover. */
.notifications-panel__item--unread {
  background: rgba(34, 211, 197, 0.06);
}

.notifications-panel__item-link {
  display: flex;
  flex: 1;
  min-width: 0;
  gap: 10px;
  color: inherit;
  text-decoration: none;
}

.notifications-panel__actions {
  display: flex;
  flex: 0 0 auto;
  gap: 2px;
}

.notifications-panel__action {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  padding: 0;
  border: none;
  border-radius: 6px;
  background: none;
  color: var(--text-muted);
  cursor: pointer;
  transition: background-color 0.1s ease, color 0.1s ease;
}

@media (hover: hover) {
  .notifications-panel__action:hover {
    background: rgba(34, 211, 197, 0.12);
    color: var(--text-primary);
  }

  .notifications-panel__action[data-role="notification-delete"]:hover {
    background: rgba(240, 166, 63, 0.15);
    color: var(--accent-orange);
  }
}

.notifications-panel__avatar {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: var(--accent);
  color: var(--text-primary);
  font-size: 11px;
  font-weight: 600;
  overflow: hidden;
}

.notifications-panel__body {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.notifications-panel__text {
  font-size: 13px;
  color: var(--text-secondary);
  overflow-wrap: break-word;
}

.notifications-panel__text strong {
  color: var(--text-primary);
}

.notifications-panel__time {
  font-size: 11px;
  color: var(--text-muted);
}

/* PR 169: "Все уведомления" (notifications.html) - a plain vertical list of the same
   .notifications-panel__item cards the bell's flyout uses (see _notification_card.html),
   just laid out full-width in the page column instead of inside a 340px popover, and
   capped at a comfortable reading width rather than stretching edge to edge. */
.notifications-page__list {
  display: flex;
  flex-direction: column;
  max-width: 640px;
}

.notifications-page__list .notifications-panel__item {
  border-radius: 8px;
}

.notifications-page__sentinel {
  height: 1px;
}

.main {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
}

.header {
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 20px 32px;
  border-bottom: 1px solid var(--border);
}

/* PR 101: reveal-on-scroll-up for the catalog's own .header (search bar, sort, "Фильтры"
   toggle), scoped via the data-role attribute so /library/catalog is the only page where
   plain .header picks this up - login/register/index/library reuse the same class for a
   static, always-in-flow header and shouldn't start sliding around. Same direction-
   detection idea as .reader-scroll-nav (PR 52, see catalog-header-scroll.js), but inverted:
   .reader-scroll-nav is a duplicate overlay that's hidden by default because the real nav
   is already visible in-flow, whereas this *is* the only header, so it has to stay visible
   without JS (and before the first scroll event) for the <noscript> pagination fallback to
   keep working - only a --hidden modifier is ever added, never a --visible one.
   position: sticky (not fixed) keeps it inside .main's own box instead of the viewport, so
   it never overlaps the sidebar, matching PR 51/52's reasoning for the same choice. */
.header[data-role="catalog-scroll-header"] {
  position: sticky;
  top: 0;
  z-index: 45;
  background: var(--bg-page);
  transform: translateY(0);
  transition: transform 0.25s ease;
}

.header[data-role="catalog-scroll-header"].header--hidden {
  transform: translateY(-100%);
}

.search-form {
  flex: 1;
  max-width: 480px;
  display: flex;
  gap: 8px;
}

.search-input {
  flex: 1;
  height: 36px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-surface);
  color: var(--text-primary);
  font-family: var(--font-mono);
  font-size: 13px;
  padding: 0 12px;
}

.search-input::placeholder {
  color: var(--text-muted);
}

.search-input:focus-visible {
  border-color: var(--accent-teal);
  box-shadow: 0 0 0 3px rgba(34, 211, 197, 0.18);
  outline: none;
}

.search-button {
  height: 36px;
  padding: 0 18px;
  border: none;
  border-radius: 6px;
  background: var(--accent);
  color: var(--text-primary);
  font-size: 13px;
  font-weight: 600;
  cursor: pointer;
  transition: transform 0.15s ease, box-shadow 0.15s ease;
}

/* PR 98/111: opens/closes .catalog-filters (see below) - a .header child (a sibling of
   .search-form, not nested inside it), pushed to .header's own right edge with
   margin-left: auto. Nesting it inside .search-form (which caps out at max-width: 480px,
   PR 98's original placement) only pushed it to that form's right edge, stranded
   somewhere in the middle of a wide header instead of the actual right edge of the page -
   see PR 111. Only rendered when there's something to filter by (catalog.html). */
.header__filters-toggle {
  height: 36px;
  padding: 0 18px;
  margin-left: auto;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: transparent;
  color: var(--text-secondary);
  font-size: 13px;
  font-weight: 600;
  font-family: inherit;
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease;
}

.content {
  flex: 1;
  padding: 24px 32px;
}

.content__hint {
  color: var(--text-muted);
  font-size: 13px;
  line-height: 1.6;
}

.content__hint code {
  font-family: var(--font-mono);
  color: var(--text-secondary);
}

/* PR 82: the "Зарегистрироваться"/"Войти" links on the auth screens had no hover
   feedback at all, unlike the rest of the interactive UI. text-decoration-color
   (not just color) transitions too, so the underline itself visibly recolors instead
   of just the letters. */
.content__hint--auth a {
  color: var(--text-secondary);
  text-decoration-color: var(--text-muted);
  text-underline-offset: 2px;
  transition: color 0.15s ease, text-decoration-color 0.15s ease;
}

@media (hover: hover) {
  .content__hint--auth a:hover {
    color: var(--accent-teal);
    text-decoration-color: var(--accent-teal);
  }
}

.form-error {
  color: var(--accent-orange);
  font-size: 13px;
}

/* PR 75: "Тайтл прочитан" on landing back on the title page after tapping past the last
   paragraph of its last chapter (tap-to-read.js) - a one-off notice, not a persistent
   status, so it gets the same plain treatment as .form-error rather than its own banner
   component; --accent-green just tells it apart as good news instead of a problem. */
.content__notice--success {
  color: var(--accent-green);
  font-size: 13px;
  margin-bottom: 16px;
}

.auth-form {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 16px;
  max-width: 320px;
  margin-top: 16px;
}

.auth-form__field {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 6px;
}

.auth-form__hint {
  color: var(--text-secondary);
  font-size: 12px;
  margin: 0;
}

.auth-form__field--checkbox {
  flex-direction: row;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--text-secondary);
  cursor: pointer;
}

/* Same custom checkmark PR 53/59/63 already gave the genre, chapter-selection and
   reader-settings checkboxes - "Запомнить меня" was the one remaining browser-default
   checkbox left over from PR 36. */
.auth-form__field--checkbox input {
  appearance: none;
  -webkit-appearance: none;
  flex: 0 0 auto;
  width: 16px;
  height: 16px;
  margin: 0;
  position: relative;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--bg-surface);
  cursor: pointer;
  transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
}

.auth-form__field--checkbox input::after {
  content: "";
  position: absolute;
  left: 4px;
  top: 1px;
  width: 4px;
  height: 8px;
  border: solid var(--bg-page);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg) scale(0);
  transition: transform 0.1s ease;
}

.auth-form__field--checkbox input:checked {
  background: var(--accent-teal);
  border-color: var(--accent-teal);
}

.auth-form__field--checkbox input:checked::after {
  transform: rotate(45deg) scale(1);
}

.auth-form__field--checkbox input:focus-visible {
  outline: none;
  border-color: var(--accent-teal);
  box-shadow: 0 0 0 3px rgba(34, 211, 197, 0.18);
}

.auth-form__label {
  font-size: 13px;
  color: var(--text-secondary);
}

.auth-form__input {
  width: 100%;
  height: 40px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-surface);
  color: var(--text-primary);
  font-family: var(--font-sans);
  font-size: 14px;
  padding: 0 12px;
}

.auth-form__input::placeholder {
  color: var(--text-muted);
}

.auth-form__input:focus-visible {
  border-color: var(--accent-teal);
  box-shadow: 0 0 0 3px rgba(34, 211, 197, 0.18);
  outline: none;
}

.auth-form .btn {
  width: 100%;
  justify-content: center;
}

/* PR 90/91: the account-details and password-change forms on /settings/account and
   /settings/security - wider than .auth-form (which is tuned for the narrow centered
   login/register screens) since both sit in the roomier settings-content column, but
   reuse .auth-form__field/__label/__input as-is rather than duplicating that styling
   under a new prefix. */
.settings-form {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 16px;
  max-width: 420px;
  margin-top: 16px;
}

/* PR 109: /settings/account avatar upload - a round preview (same treatment as
   .profile__avatar, scaled the same 96px) that's also the file-picker trigger, with a
   pencil overlay fading in on hover/focus instead of a separate file field + "Загрузить"
   button. */
.avatar-upload {
  position: relative;
  display: inline-flex;
  width: 96px;
  height: 96px;
  border-radius: 50%;
  cursor: pointer;
}

.avatar-upload:focus-within {
  outline: 2px solid var(--accent-teal);
  outline-offset: 3px;
}

.avatar-upload__preview {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  overflow: hidden;
  background: var(--accent);
  color: var(--text-primary);
}

.avatar-upload__initials {
  font-size: 32px;
  font-weight: 600;
}

.avatar-upload__overlay {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.45);
  color: #fff;
  opacity: 0;
  transition: opacity 0.15s ease;
  pointer-events: none;
}

.avatar-upload:focus-within .avatar-upload__overlay {
  opacity: 1;
}

@media (hover: hover) {
  .avatar-upload:hover .avatar-upload__overlay {
    opacity: 1;
  }
}

/* Visually hidden but still focusable/keyboard-operable - the visible trigger is the
   <label> wrapping this input, same technique as a "sr-only" utility class. */
.avatar-upload__input {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.auth-form__input--textarea {
  height: auto;
  min-height: 96px;
  padding: 10px 12px;
  font-family: inherit;
  resize: vertical;
}

/* PR 92: /profile - read-only, so no .settings-form here, just the same avatar treatment
   as .sidebar__avatar (PR 88) scaled up, next to the account's own display fields. */
.profile {
  display: flex;
  align-items: flex-start;
  gap: 24px;
  margin-top: 20px;
  max-width: 480px;
}

.profile__avatar {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 96px;
  height: 96px;
  border-radius: 50%;
  background: var(--accent);
  color: var(--text-primary);
  font-size: 32px;
  font-weight: 600;
}

/* PR 173: opens image-lightbox.js's fullscreen viewer, same reasoning as
   .title-hero__cover/img.paragraph-comment__attachment above - only the uploaded-avatar
   <img> gets the cursor, never .profile__avatar itself, so the plain initials text (no
   <img> at all, profile.html) doesn't look clickable when there's nothing to click open. */
.profile__avatar img {
  cursor: zoom-in;
}

.profile__info {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
}

.profile__name {
  font-size: 20px;
  font-weight: 700;
  margin: 0;
}

.profile__bio {
  color: var(--text-secondary);
  font-size: 14px;
  margin: 0;
}

.profile__meta {
  color: var(--text-muted);
  font-size: 13px;
  margin: 0 0 8px;
}

/* PR 122: "Читает сейчас"/"Библиотека" sections on /profile - same heading treatment as
   .reader-settings-section__title, unlike .profile above these aren't capped to 480px
   since the library grid needs the full content width. */
.profile-section {
  margin-top: 28px;
}

.profile-section__title {
  font-size: 14px;
  font-weight: 700;
  color: var(--text-secondary);
  margin: 0 0 12px;
}

.profile-current-read__name {
  font-size: 16px;
  font-weight: 700;
  text-decoration: none;
}

@media (hover: hover) {
  .profile-current-read__name:hover {
    color: var(--accent-teal);
  }
}

.profile-current-read__meta {
  color: var(--text-muted);
  font-size: 13px;
  margin: 4px 0 0;
}

/* PR 160: GitHub-reference redesign of the heatmap below - shared cell/gap sizing so the
   month-label row, weekday column, day grid and legend swatches all line up on the same
   11px grid without repeating the magic numbers four times. */
.reading-calendar-card {
  --reading-calendar-cell: 11px;
  --reading-calendar-gap: 3px;
  /* Intensity relative to this user's own busiest day in the window (see
     _build_reading_calendar's level calculation), not a fixed absolute scale - a light
     reader's best day and a heavy reader's best day both land on level 4, in the site's
     accent teal rather than GitHub's own green. Shared as variables (not just set
     directly on .reading-calendar__day--level-N below) so the always-present legend
     swatches can reuse the same colors under their own class names, instead of the
     legend colliding with the "no level-N cell present" assertions the day grid's own
     classes are used for elsewhere. */
  --reading-calendar-level-1: rgba(34, 211, 197, 0.28);
  --reading-calendar-level-2: rgba(34, 211, 197, 0.5);
  --reading-calendar-level-3: rgba(34, 211, 197, 0.75);
  --reading-calendar-level-4: var(--accent-teal);
}

/* Weekday column (left) + the scrollable months/day-grid stack (right) - only the latter
   scrolls horizontally (see .reading-calendar-scroll below), so the weekday labels stay
   put at the left edge the same way GitHub's own calendar keeps them pinned. */
.reading-calendar-body {
  display: flex;
  align-items: flex-start;
  gap: 6px;
}

.reading-calendar-weekdays {
  display: grid;
  grid-template-rows: repeat(7, var(--reading-calendar-cell));
  gap: var(--reading-calendar-gap);
  /* Lines the first row (Sunday) up with the day grid's own first row, which sits below
     the months row + its gap inside .reading-calendar-scroll. */
  margin-top: calc(14px + var(--reading-calendar-gap));
  font-size: 10px;
  line-height: var(--reading-calendar-cell);
  color: var(--text-muted);
  white-space: nowrap;
}

/* PR 136: the GitHub-style reading-activity heatmap on /profile - a flat sequence of day
   cells (app/api/profile.py's _build_reading_calendar(), oldest first, padded back to
   the most recent Sunday on/before the window's own start) auto-flowed into 7-row
   columns, so the server never has to compute per-cell grid coordinates itself - it just
   emits the days in order and lets grid-auto-flow: column place them. */
.reading-calendar {
  display: grid;
  grid-auto-flow: column;
  /* Fixed track size, not 1fr - 1fr rows are shares of the grid container's own block
     size, but .reading-calendar never sets a height of its own (it's sized by its
     content), so there's nothing for "1fr" to divide and every cell fell into a single
     tall column instead of wrapping into 7-row weeks. Matches .reading-calendar__day's
     own explicit 11px so a row is exactly one cell tall, no more. */
  grid-template-rows: repeat(7, var(--reading-calendar-cell));
  gap: var(--reading-calendar-gap);
}

/* PR 160: months row + day grid scroll together as one unit (overflow-x: auto here,
   rather than on .reading-calendar alone) so a month label never drifts out of sync with
   the column of cells it labels. At ~53 columns the full grid is wider than a phone
   screen, same "wide content scrolls in its own container" rule as everywhere else wide
   in this app (catalog tables, code-adjacent content). */
.reading-calendar-scroll {
  display: flex;
  flex-direction: column;
  gap: var(--reading-calendar-gap);
  overflow-x: auto;
  padding-bottom: 4px;
}

/* One label per day-grid column (app/api/profile.py's _month_labels(), empty string for
   every column that isn't where a month starts) - a single-row grid using the exact same
   column size/gap as .reading-calendar above it, so column N here sits directly above
   column N of the day grid. Labels aren't clipped to their own 11px-wide cell
   (overflow: visible is the grid default) - like GitHub's own graph, the ~3-character
   abbreviation just overflows rightward over the next (empty) label cells. */
.reading-calendar-months {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: var(--reading-calendar-cell);
  gap: var(--reading-calendar-gap);
  height: 14px;
  font-size: 10px;
  line-height: 14px;
  color: var(--text-muted);
  white-space: nowrap;
}

/* Legend for the 5 intensity levels, bottom-left of the card, flush with the weekday
   column/day grid above it (.reading-calendar-weekdays) - its own swatch class (not
   .reading-calendar__day--level-N) so a legend, which always shows all 5 levels
   regardless of this user's actual data, can never be mistaken by a substring check for
   an actual day cell reaching that level (see tests/test_api_profile.py). Same colors
   via the --reading-calendar-level-N variables above. */
.reading-calendar-legend {
  display: flex;
  align-items: center;
  gap: 4px;
  justify-content: flex-start;
  margin-top: 8px;
  font-size: 11px;
  color: var(--text-muted);
}

.reading-calendar-legend__swatch {
  width: var(--reading-calendar-cell);
  height: var(--reading-calendar-cell);
  border-radius: 2px;
  background: var(--bg-surface);
  border: 1px solid var(--border);
}

.reading-calendar-legend__swatch--level-1 {
  background: var(--reading-calendar-level-1);
  border-color: transparent;
}

.reading-calendar-legend__swatch--level-2 {
  background: var(--reading-calendar-level-2);
  border-color: transparent;
}

.reading-calendar-legend__swatch--level-3 {
  background: var(--reading-calendar-level-3);
  border-color: transparent;
}

.reading-calendar-legend__swatch--level-4 {
  background: var(--reading-calendar-level-4);
  border-color: transparent;
}

.reading-calendar__day {
  width: 11px;
  height: 11px;
  border-radius: 2px;
  background: var(--bg-surface);
  border: 1px solid var(--border);
}

.reading-calendar__day--level-1 {
  background: var(--reading-calendar-level-1);
  border-color: transparent;
}

.reading-calendar__day--level-2 {
  background: var(--reading-calendar-level-2);
  border-color: transparent;
}

.reading-calendar__day--level-3 {
  background: var(--reading-calendar-level-3);
  border-color: transparent;
}

.reading-calendar__day--level-4 {
  background: var(--reading-calendar-level-4);
  border-color: transparent;
}

/* PR 160: custom tooltip replacing the native `title` attribute (see reading-calendar-
   tooltip.js) - same floating-panel language as .profile-menu__panel/.paragraph-menu__panel
   (fixed position, computed from the hovered cell's own getBoundingClientRect(), fade
   in/out rather than toggling display). white-space: pre-line renders the "\n"-joined
   label lines (app/api/profile.py) as real line breaks without needing innerHTML. */
.reading-calendar-tooltip {
  position: fixed;
  z-index: 40;
  max-width: 240px;
  padding: 8px 10px;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: 0 10px 24px rgba(0, 0, 0, 0.35);
  font-size: 12px;
  line-height: 1.5;
  color: var(--text-secondary);
  white-space: pre-line;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s ease;
}

.reading-calendar-tooltip--open {
  opacity: 1;
}

.recent__title {
  font-size: 16px;
  font-weight: 700;
  margin: 24px 0 12px;
}

/* Personal-feature-needs-an-account state (originally "Читаю" locked for anonymous
   visitors - Claude Design import "RanobeLib Library Locked", see CLAUDE.md, "Дизайн"),
   generalized for reuse by "Загрузки" (PR 17) too. */

.locked-feature {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 22px;
  max-width: 420px;
  margin: 0 auto;
  padding: 48px 40px;
  border: 1px solid var(--border);
  border-radius: 16px;
  background: var(--bg-surface);
}

.locked-feature__icon {
  position: relative;
  width: 64px;
  height: 74px;
}

.locked-feature__icon-shackle {
  position: absolute;
  top: 0;
  left: 14px;
  width: 36px;
  height: 34px;
  box-sizing: border-box;
  border: 6px solid #4b4a58;
  border-bottom: none;
  border-radius: 18px 18px 0 0;
}

.locked-feature__icon-body {
  position: absolute;
  bottom: 0;
  width: 64px;
  height: 46px;
  border-radius: 8px;
  background: linear-gradient(135deg, var(--accent), #5b21b6);
}

.locked-feature__icon-keyhole {
  position: absolute;
  bottom: 16px;
  left: 28px;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #1c1230;
}

.locked-feature__icon-keyhole-stem {
  position: absolute;
  bottom: 11px;
  left: 30.5px;
  width: 3px;
  height: 9px;
  background: #1c1230;
}

.locked-feature__copy {
  text-align: center;
}

.locked-feature__title {
  margin: 0 0 8px;
  font-size: 16px;
  font-weight: 700;
  color: var(--text-primary);
}

.locked-feature__text {
  margin: 0;
  font-size: 14px;
  line-height: 1.6;
  color: var(--text-muted);
}

.locked-feature__text a {
  color: #a78bfa;
}

.locked-feature__text a:hover {
  color: #c4b5fd;
}

.locked-feature__cta {
  margin-top: 4px;
  padding: 10px 22px;
  border-radius: 8px;
  background: var(--accent);
  color: var(--text-primary);
  font-size: 14px;
  font-weight: 600;
  text-decoration: none;
  display: inline-block;
  transition: transform 0.15s ease, box-shadow 0.15s ease;
}

.library-tabs {
  display: flex;
  gap: 20px;
  padding: 0 32px;
  border-bottom: 1px solid var(--border);
}

.library-tabs__link {
  position: relative;
  padding: 14px 0;
  color: var(--text-muted);
  text-decoration: none;
  font-size: 14px;
  font-weight: 600;
  transition: color 0.15s ease;
}

.library-tabs__link::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -1px;
  height: 2px;
  background: var(--accent-teal);
  transform: scaleX(0);
  transform-origin: left;
}

@media (hover: hover) {
  .library-tabs__link:hover {
    color: var(--text-secondary);
  }
}

@keyframes library-tab-indicator-in {
  from {
    transform: scaleX(0);
  }
  to {
    transform: scaleX(1);
  }
}

.library-tabs__link--active {
  color: var(--text-primary);
}

.library-tabs__link--active::after {
  transform: scaleX(1);
  animation: library-tab-indicator-in 0.2s ease;
}

@keyframes library-tabs-content-in {
  from {
    opacity: 0;
    transform: translateY(6px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* PR 47: /library and /library/catalog (the "Читаю"/"Все тайтлы" tabs) are separate
   server-rendered pages, not a client-side tab switch - so this fades/slides each page's
   own content in on load rather than animating a shared element between them. Applies at
   every breakpoint (unlike PR 46's mobile-only .main animation, which exists to echo the
   sidebar becoming a bottom tab bar there) - these two tabs render identically above and
   below the mobile breakpoint. */
.library-tabs-content {
  animation: library-tabs-content-in 0.18s ease-out;
}

/* PR 85: replaces the collapsible .catalog-genres popup (PR 38/45) with a permanently
   visible right-hand column, laid out like a store's filter sidebar - .catalog-layout
   is the flex row (results left, filters right), .catalog-layout__results wraps the
   grid so it can shrink properly next to a fixed-width sidebar. */
.catalog-layout {
  display: flex;
  align-items: flex-start;
  gap: 24px;
}

.catalog-layout__results {
  flex: 1;
  min-width: 0;
}

/* PR 102: floating "back to top" button for the catalog's infinite-scroll grid (PR 15/24) -
   escaping a long, still-growing card grid otherwise takes a slow manual scroll. Same
   bottom-right corner and z-index convention as .toc__export-panel (PR 51), though the two
   never actually share a page - this only renders in catalog.html. Visibility is toggled
   via the `hidden` attribute (catalog-back-to-top.js), not a class - `display: flex` below
   needs the explicit [hidden] override since it would otherwise outrank the browser's own
   `[hidden] { display: none }` UA rule, same reasoning as .download-ready (PR 50). */
.back-to-top {
  position: fixed;
  bottom: 24px;
  right: 32px;
  z-index: 45;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--bg-surface);
  color: var(--text-primary);
  font-size: 18px;
  line-height: 1;
  cursor: pointer;
  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35);
  transition: transform 0.15s ease, border-color 0.15s ease, right 0.18s ease;
}

.back-to-top[hidden] {
  display: none;
}

@media (hover: hover) {
  .back-to-top:hover {
    border-color: var(--accent-teal);
    transform: translateY(-3px);
  }
}

/* PR 127: .catalog-filters (240px wide, PR 98) sits flush against the same 32px page
   padding this button's own `right: 32px` uses, so an open panel would otherwise sit
   right on top of it. Shifted left by the panel's own width plus .catalog-layout's 24px
   column gap (240 + 24 = 264px) whenever catalog-filters-toggle.js opens it - `right`
   is in the transition list above so the two move in step with the panel's own
   open/close animation, not a jump cut. */
@media (min-width: 641px) {
  .back-to-top--filters-open {
    right: 296px;
  }
}

@media (max-width: 640px) {
  .back-to-top {
    /* Same 76px clearance above the fixed bottom tab bar (PR 46) already used by
       .download-ready and .toc__export-panel on mobile. */
    bottom: 76px;
    right: 12px;
  }

  /* On mobile .catalog-filters is a fullscreen modal (PR 103), not a side column - there's
     no sensible "shifted" position next to it, so this button just disappears for as
     long as the modal covers the screen instead. */
  .back-to-top--filters-open {
    display: none;
  }
}

/* PR 98: fades/slides in whenever the panel goes from `hidden` to shown (same one-shot
   entrance animation pattern as .library-tabs-content-in - toggling `hidden` restarts a
   CSS `animation` reliably, unlike `transition`, which needs the element already
   rendered to detect a property change). Closing needs the reverse:
   catalog-filters-toggle.js adds --closing first to transition opacity/transform down,
   then sets `hidden`
   itself once that finishes - `hidden` can't be delayed via CSS alone. */
.catalog-filters {
  flex: 0 0 240px;
  position: sticky;
  top: 16px;
  max-height: calc(100vh - 32px);
  overflow-y: auto;
  padding: 16px;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  animation: catalog-filters-in 0.18s ease-out;
  transition: opacity 0.15s ease, transform 0.15s ease;
}

.catalog-filters--closing {
  opacity: 0;
  transform: translateY(-6px);
  pointer-events: none;
}

@keyframes catalog-filters-in {
  from {
    opacity: 0;
    transform: translateY(6px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

/* PR 98: title + close button on one row, replacing the title's own margin-bottom. */
.catalog-filters__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin: 0 0 12px;
}

.catalog-filters__title {
  font-size: 15px;
  margin: 0;
}

.catalog-filters__close {
  flex: 0 0 auto;
  background: none;
  border: none;
  padding: 4px;
  color: var(--text-muted);
  font-size: 18px;
  line-height: 1;
  cursor: pointer;
  transition: color 0.15s ease;
}

@media (hover: hover) {
  .catalog-filters__close:hover {
    color: var(--accent-teal);
  }
}

.catalog-filters__section + .catalog-filters__section {
  margin-top: 20px;
  padding-top: 20px;
  border-top: 1px solid var(--border);
}

.catalog-filters__section-title {
  margin: 0 0 10px;
}

/* PR 99: the section title is now a button (accordion header) instead of plain text -
   same font styling the <h3> used to carry directly, reset to look like a heading again
   since a <button> comes with its own UA-agent chrome by default. */
.catalog-filters__section-toggle {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  width: 100%;
  padding: 0;
  border: none;
  background: none;
  font: inherit;
  font-size: 12px;
  font-weight: 600;
  color: var(--text-secondary);
  text-align: left;
  cursor: pointer;
}

.catalog-filters__section-toggle::after {
  content: "";
  flex: 0 0 auto;
  width: 0;
  height: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-top: 5px solid var(--text-muted);
  transform: rotate(180deg);
  transition: transform 0.15s ease;
}

.catalog-filters__section--collapsed .catalog-filters__section-toggle::after {
  transform: rotate(0deg);
}

/* PR 99: collapse animation - max-height is JS-driven (catalog-filters-accordion.js),
   from 0 up to the options list's own measured scrollHeight, since the list's real
   height varies a lot between sections (a couple of country radios vs 50+ genre
   checkboxes) and can't be hardcoded as a single fixed value. */
.catalog-filters__options {
  display: flex;
  flex-direction: column;
  gap: 10px;
  overflow: hidden;
  transition: max-height 0.2s ease;
}

.catalog-filters__option {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--text-secondary);
  cursor: pointer;
}

/* Custom checkmark (PR 53) - the browser-default checkbox otherwise stood out against
   the rest of the dark theme, and its checked color isn't something CSS can retint on
   its own. `appearance: none` drops the native box entirely; the border/background here
   replace it, and the ::after checkmark is drawn with a rotated border rather than an
   image so it inherits the same currentColor-free palette as everything else on the
   page. `:focus-visible` (not `:focus`) matches .toc__export-format's convention
   elsewhere in this file - a visible keyboard-focus ring without one on mouse click. */
.catalog-filters__option input[type="checkbox"] {
  appearance: none;
  -webkit-appearance: none;
  flex: 0 0 auto;
  width: 16px;
  height: 16px;
  margin: 0;
  position: relative;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--bg-surface);
  cursor: pointer;
  transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
}

.catalog-filters__option input[type="checkbox"]::after {
  content: "";
  position: absolute;
  left: 4px;
  top: 1px;
  width: 4px;
  height: 8px;
  border: solid var(--bg-page);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg) scale(0);
  transition: transform 0.1s ease;
}

.catalog-filters__option input[type="checkbox"]:checked {
  background: var(--accent-teal);
  border-color: var(--accent-teal);
}

.catalog-filters__option input[type="checkbox"]:checked::after {
  transform: rotate(45deg) scale(1);
}

.catalog-filters__option input[type="checkbox"]:focus-visible {
  outline: none;
  border-color: var(--accent-teal);
  box-shadow: 0 0 0 3px rgba(34, 211, 197, 0.18);
}

/* PR 85: the "Страна" section is single-select (Catalog.list_titles(country=...) takes
   one id, not a list like genres) - a radio group, not checkboxes, so it needs its own
   circular custom control rather than reusing the checkbox's square/checkmark one. Same
   currentColor-free, appearance:none approach as the checkbox above, just a dot in a
   circle instead of a checkmark in a square. */
.catalog-filters__option input[type="radio"] {
  appearance: none;
  -webkit-appearance: none;
  flex: 0 0 auto;
  width: 16px;
  height: 16px;
  margin: 0;
  position: relative;
  border: 1px solid var(--border);
  border-radius: 50%;
  background: var(--bg-surface);
  cursor: pointer;
  transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
}

.catalog-filters__option input[type="radio"]::after {
  content: "";
  position: absolute;
  inset: 3px;
  border-radius: 50%;
  background: var(--bg-page);
  transform: scale(0);
  transition: transform 0.1s ease;
}

.catalog-filters__option input[type="radio"]:checked {
  background: var(--accent-teal);
  border-color: var(--accent-teal);
}

.catalog-filters__option input[type="radio"]:checked::after {
  transform: scale(1);
}

.catalog-filters__option input[type="radio"]:focus-visible {
  outline: none;
  border-color: var(--accent-teal);
  box-shadow: 0 0 0 3px rgba(34, 211, 197, 0.18);
}

.title-card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  gap: 20px;
  margin-top: 16px;
}

.title-card-grid__sentinel {
  height: 1px;
}

.title-card {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 8px;
  text-decoration: none;
  color: inherit;
  /* PR 60: the sidebar's width transition (.sidebar, PR 39) resizes .content on every
     animation frame, which forces .title-card-grid's auto-fill columns to relayout every
     card in it - fine for one page of cards, but infinite scroll (PR 15/24) never trims
     the DOM, so a catalog scrolled through a while accumulates hundreds of them and the
     transition visibly janks. content-visibility skips layout/paint entirely for cards
     currently far outside the viewport, so a reflow-triggering event like this only ever
     costs what's actually on screen, however many more are loaded below. The
     contain-intrinsic-size fallback keeps the grid's row heights (and thus scrollbar/
     scroll position) stable before a card has ever been rendered once; the `auto` keyword
     switches to remembering each card's real rendered height afterwards, since it varies
     with title length and isn't safe to hardcode.  */
  content-visibility: auto;
  contain-intrinsic-size: auto 260px;
}

/* PR 141: <img> can't take generated content (::before/::after) in any browser, so the
   darkening overlay below lives on this wrapping <span> instead (_title_card.html) -
   its own overflow:hidden is also what stops the zoomed .title-card__cover below from
   spilling out past the card's rounded corners. Wraps the no-cover placeholder <div>
   the same way (_title_card.html's other branch) rather than only the <img> case, so
   both react to hover identically instead of one silently doing nothing. */
.title-card__cover-wrap {
  position: relative;
  overflow: hidden;
  border-radius: 8px;
}

.title-card__cover-wrap::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0) 60%);
  opacity: 0;
  transition: opacity 0.2s ease;
}

@media (hover: hover) {
  .title-card:hover .title-card__cover-wrap::after {
    opacity: 1;
  }

  .title-card:hover .title-card__cover {
    transform: scale(1.05);
  }
}

.title-card__cover {
  width: 100%;
  aspect-ratio: 2 / 3;
  border-radius: 8px;
  object-fit: cover;
  background: var(--bg-surface);
  transition: transform 0.2s ease;
}

.title-card__name {
  font-size: 13px;
  font-weight: 600;
  color: var(--text-primary);
  line-height: 1.4;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
  text-overflow: ellipsis;
  overflow-wrap: break-word;
}

.title-card:hover .title-card__name {
  color: var(--accent-teal);
}

.title-card__status {
  align-self: flex-start;
}

.title-card__meta {
  font-size: 12px;
  color: var(--text-secondary);
}

.title-card__meta--muted {
  color: var(--text-muted);
}

.title-card__remove {
  position: absolute;
  top: 6px;
  right: 6px;
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.55);
  border: none;
  border-radius: 50%;
  color: #fff;
  font-size: 16px;
  line-height: 1;
  cursor: pointer;
  transition: background 0.15s ease;
  /* Bug fix: .title-card__cover-wrap (PR 141) is position:relative for its own ::after
     overlay, which put it in the same z-index:auto positioned stacking layer as this
     button - since it sits later in _title_card.html than .title-card__quickview, it
     started painting (and intercepting clicks) on top of that one specifically, purely
     because of DOM order. This button and .title-card__favorite below happen to come
     after the wrap in the DOM so were unaffected by that particular ordering, but an
     explicit z-index here removes the dependency on DOM order for any of the three
     corner buttons, not just the one that broke. */
  z-index: 1;
}

@media (hover: hover) {
  .title-card__remove:hover {
    background: var(--accent-orange);
  }
}

/* PR 117: "quick view" trigger - top-left corner so it never collides with
   .title-card__remove (top-right, "Недавние" only). Same circular-button treatment. */
.title-card__quickview {
  position: absolute;
  top: 6px;
  left: 6px;
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.55);
  border: none;
  border-radius: 50%;
  color: #fff;
  cursor: pointer;
  transition: background 0.15s ease;
  /* See the matching comment on .title-card__remove above - this is the one of the three
     corner buttons that actually sits *before* .title-card__cover-wrap in the DOM
     (_title_card.html), so it's the one that was genuinely unclickable without this. */
  z-index: 1;
}

@media (hover: hover) {
  .title-card__quickview:hover {
    background: var(--accent-teal);
  }
}

/* PR 123: favorite-star toggle - top-right corner, only ever rendered on the "Читаю"
   grid (library.html), which never shows .title-card__remove ("Недавние" only), so the
   two never collide despite sharing that same corner. */
.title-card__favorite {
  position: absolute;
  top: 6px;
  right: 6px;
  width: 24px;
  height: 24px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.55);
  border: none;
  border-radius: 50%;
  color: #fff;
  cursor: pointer;
  transition: background 0.15s ease;
  /* See the matching comment on .title-card__remove above. */
  z-index: 1;
}

.title-card__favorite--active {
  color: var(--accent-orange);
}

.title-card__favorite--active svg {
  fill: currentColor;
}

@media (hover: hover) {
  .title-card__favorite:hover {
    background: var(--accent-orange);
    color: #fff;
  }
}

/* PR 123: the one favorite title, singled out on the profile page's own "Избранное"
   section - an accented frame around the whole card, same corner-radius language as the
   card's own cover image, rather than a new component. */
.title-card--favorite {
  border: 2px solid var(--accent-orange);
  border-radius: 12px;
  padding: 8px;
}

.reading-progress {
  height: 3px;
  background: var(--border);
  border-radius: 2px;
  overflow: hidden;
}

.reading-progress__fill {
  height: 100%;
  background: var(--accent-teal);
  border-radius: 2px;
}

.reading-progress--hero {
  max-width: 320px;
  margin-top: 4px;
}

.title-hero {
  display: flex;
  gap: 28px;
  padding-bottom: 24px;
  border-bottom: 1px solid var(--border);
}

.title-hero__cover {
  width: 170px;
  height: 250px;
  flex: 0 0 170px;
  border-radius: 8px;
  object-fit: cover;
  background: var(--bg-surface);
  /* PR 142: opens image-lightbox.js's fullscreen viewer, same as .reader-content img
     already does - set here rather than inline via JS, since image-lightbox.js's own
     click listener for this class is delegated and may only ever see a cover injected
     long after load (the quick-view modal's own copy of this class, PR 117), with no
     single "attach time" a script-set inline style could hook into. */
  cursor: zoom-in;
}

.title-hero__info {
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.title-hero__info > .btn {
  align-self: flex-start;
}

.title-hero__name {
  font-size: 34px;
  font-weight: 800;
  letter-spacing: -0.5px;
  margin: 0;
}

.title-hero__alt-name {
  color: var(--text-muted);
  margin: 0;
}

.title-hero__badges {
  display: flex;
  gap: 8px;
  flex-wrap: wrap;
}

.badge {
  background: var(--bg-surface);
  border-radius: 4px;
  font-size: 11px;
  padding: 3px 9px;
}

.badge--muted {
  background: transparent;
  border: 1px solid var(--border);
  color: var(--text-muted);
}

.badge--link {
  text-decoration: none;
}

.badge--link:hover {
  color: var(--accent-teal);
}

.title-hero__meta {
  display: flex;
  gap: 14px;
  margin-top: 6px;
  color: var(--text-muted);
  font-size: 13px;
}

.title-size-estimate {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}

.spinner {
  width: 11px;
  height: 11px;
  border: 2px solid var(--border);
  border-top-color: var(--accent-teal);
  border-radius: 50%;
  animation: spin 0.7s linear infinite;
}

@keyframes spin {
  to {
    transform: rotate(360deg);
  }
}

.title-hero__actions {
  display: flex;
  align-items: center;
  gap: 14px;
  margin-top: 14px;
}

.title-summary {
  padding: 20px 0;
  color: var(--text-secondary);
  line-height: 1.7;
  max-width: 720px;
}

.title-credits {
  display: flex;
  flex-direction: column;
  gap: 8px;
  font-size: 13px;
  color: var(--text-secondary);
}

.title-credits__label {
  color: var(--text-muted);
  margin-right: 6px;
}

/* PR 117: "quick view" modal - opened from the eye icon on any title card
   (.title-card__quickview above), fetched by title-quickview.js from
   GET /titles/{slug}/quickview. Reuses .title-hero__cover/__info/__alt-name/__meta/
   __badges and .title-summary as-is (same partial data the full title page shows,
   app/templates/_title_quickview.html) - only the cover/name get scoped, smaller sizing
   here, tuned for a compact modal instead of the page's full-width hero. Same
   fixed-overlay/backdrop-click/Escape/close-button pattern as .image-lightbox (PR 66),
   one level below its z-index: 100 since a preview modal shouldn't outrank a fullscreen
   image viewer if both were somehow open. */
.title-quickview-modal {
  position: fixed;
  inset: 0;
  z-index: 90;
  display: none;
  align-items: center;
  justify-content: center;
  padding: 20px;
  background: rgba(0, 0, 0, 0.6);
}

.title-quickview-modal--open {
  display: flex;
}

.title-quickview-modal__panel {
  position: relative;
  width: 100%;
  max-width: 560px;
  max-height: 85vh;
  overflow-y: auto;
  padding: 24px;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  box-shadow: 0 20px 48px rgba(0, 0, 0, 0.4);
}

.title-quickview-modal__close {
  position: absolute;
  top: 12px;
  right: 12px;
  background: none;
  border: none;
  padding: 4px;
  color: var(--text-muted);
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
  transition: color 0.15s ease;
}

@media (hover: hover) {
  .title-quickview-modal__close:hover {
    color: var(--accent-teal);
  }
}

.title-quickview {
  display: flex;
  gap: 20px;
}

.title-quickview .title-hero__cover {
  width: 110px;
  height: 160px;
  flex: 0 0 110px;
}

.title-quickview__name {
  font-size: 20px;
  font-weight: 700;
  margin: 0;
}

.title-quickview__summary {
  padding: 8px 0 0;
  max-width: none;
  font-size: 13px;
}

@media (max-width: 480px) {
  .title-quickview {
    flex-direction: column;
  }

  .title-quickview .title-hero__cover {
    width: 100%;
    height: auto;
    aspect-ratio: 2 / 3;
    flex-basis: auto;
  }
}

.btn {
  height: 44px;
  padding: 0 26px;
  border-radius: 8px;
  font-size: 15px;
  font-weight: 700;
  border: none;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  color: var(--text-primary);
  font-family: inherit;
  text-decoration: none;
  transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}

.btn--primary {
  background: var(--accent);
}

.btn--secondary {
  background: transparent;
  border: 1px solid var(--border);
}

/* PR 138: the "Сохранить" buttons on every /settings/* form had no focus-visible ring at
   all, unlike the inputs/checkboxes right above them in the same forms - a keyboard user
   tabbing through a settings form lost the focus indicator entirely on reaching the
   submit button. Applies to every .btn site-wide (not just settings), same as the ring
   already shared by .auth-form__input/.toc__export-format/the checkboxes. */
.btn:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px rgba(34, 211, 197, 0.35);
}

/* One shared hover treatment for every button-like element in the app - previously some
   had no hover feedback at all (.btn--primary, .btn--sm, .search-button,
   .locked-feature__cta) and others changed color/border instantly with no transition
   (.btn--secondary, .reader-export__link).

   Gated to hover-capable devices (mouse/trackpad) - a plain :hover rule applies on tap
   on a touchscreen and only clears on the next unrelated tap, leaving the scaled-up/
   shadowed state "stuck" until then (same issue fixed for the sidebar in PR 42). */
@media (hover: hover) {
  .btn--secondary:hover,
  .header__filters-toggle:hover {
    border-color: var(--accent-teal);
  }

  .btn:hover,
  .search-button:hover,
  .locked-feature__cta:hover,
  .reader-export__link:hover {
    transform: scale(1.03);
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
  }

  .toc__export-format:hover {
    border-color: var(--accent-teal);
  }

  .catalog-filters__option:hover input[type="checkbox"]:not(:checked) {
    border-color: var(--accent-teal);
  }

  .catalog-filters__option:hover input[type="radio"]:not(:checked) {
    border-color: var(--accent-teal);
  }

  .toc__chapter-checkbox:hover:not(:checked) {
    border-color: var(--accent-teal);
  }

  .reader-settings__toggle:hover:not(:checked) {
    border-color: var(--accent-teal);
  }

  .auth-form__field--checkbox:hover input:not(:checked) {
    border-color: var(--accent-teal);
  }

  .translation-choice__link:hover {
    border-color: var(--accent-teal);
    transform: translateY(-2px);
    box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35);
  }
}

.btn--sm {
  height: 26px;
  padding: 0 12px;
  border-radius: 5px;
  font-size: 11px;
  font-weight: 600;
  background: var(--accent);
}

.toc {
  margin-top: 28px;
  /* Clearance for the fixed export panel below (PR 51) so it never sits over the last
     couple of chapters/checkboxes on a long table of contents - reserved unconditionally
     (not just while the panel is actually visible) so the list doesn't jump/reflow as it
     slides in and out. */
  padding-bottom: 96px;
}

.toc__header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 12px;
  margin-bottom: 16px;
}

.toc__export-panel {
  /* PR 37 originally made this `position: sticky`, which only holds an element within
     its own containing block (here, the short `.toc__header` row) - once the page
     scrolls past that row, the panel scrolls away with it despite `sticky`, which is
     exactly the bug PR 51 fixes: `fixed` anchors to the viewport instead, so it stays
     reachable for the entire length of a long chapter list. Bottom-anchored rather than
     top - avoids the top-right "download ready" toast (PR 50) and needs no coordination
     with the mobile fixed account header up there. */
  position: fixed;
  bottom: 24px;
  right: 32px;
  z-index: 45;
  opacity: 1;
  transform: translateY(0);
  transition: opacity 0.2s ease, transform 0.2s ease;
}

.toc__export-panel--js:not(.toc__export-panel--visible) {
  opacity: 0;
  transform: translateY(8px);
  pointer-events: none;
}

.toc__export {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
}

.toc__export-format {
  height: 36px;
  padding: 0 10px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg-surface);
  color: var(--text-primary);
  font-family: var(--font-mono);
  font-size: 13px;
  cursor: pointer;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}

.toc__export-format:focus-visible {
  border-color: var(--accent-teal);
  box-shadow: 0 0 0 3px rgba(34, 211, 197, 0.18);
  outline: none;
}

/* Custom listbox dropdown (PR 54) - built by custom-dropdown.js over a hidden native
   <select> (see that file's docstring for why). The trigger reuses .toc__export-format's
   box styling (border/height/background/focus ring) as-is; everything here is additions
   specific to the popup itself. */
.dropdown {
  position: relative;
  display: inline-block;
}

.dropdown__native {
  display: none;
}

.dropdown__trigger {
  appearance: none;
  -webkit-appearance: none;
  display: inline-flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  width: 100%;
  text-align: left;
  font-family: inherit;
}

.dropdown__trigger::after {
  content: "";
  flex: 0 0 auto;
  width: 0;
  height: 0;
  margin-top: 2px;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-top: 5px solid var(--text-muted);
  transition: transform 0.15s ease;
}

.dropdown--open .dropdown__trigger::after {
  transform: rotate(180deg);
}

.dropdown__listbox {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  z-index: 30;
  min-width: 100%;
  margin: 0;
  padding: 6px;
  list-style: none;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: 0 10px 24px rgba(0, 0, 0, 0.35);
  max-height: 280px;
  overflow-y: auto;
  opacity: 0;
  transform: translateY(-6px);
  pointer-events: none;
  transition: opacity 0.15s ease, transform 0.15s ease;
}

.dropdown--open .dropdown__listbox {
  opacity: 1;
  transform: translateY(0);
  pointer-events: auto;
}

.dropdown__option {
  padding: 8px 10px;
  border-radius: 6px;
  font-size: 13px;
  color: var(--text-secondary);
  white-space: nowrap;
  cursor: pointer;
  transition: background-color 0.1s ease, color 0.1s ease;
}

.dropdown__option--active {
  background: rgba(34, 211, 197, 0.12);
  color: var(--text-primary);
}

.dropdown__option--selected {
  color: var(--accent-teal);
  font-weight: 600;
}

.toc__title {
  font-size: 18px;
  font-weight: 700;
  margin: 0;
}

.toc__volume {
  margin-bottom: 20px;
}

.toc__volume-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 8px;
}

.toc__volume-title {
  font-size: 14px;
  font-weight: 700;
  color: var(--text-secondary);
  margin: 0;
}

.toc__chapters {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
}

.toc__chapter {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 10px;
  border-bottom: 1px solid var(--border);
  font-size: 13px;
}

/* Custom checkmark (PR 59) - the same treatment PR 53 gave the catalog genre checkboxes
   (.catalog-filters__option input[type="checkbox"] above), so chapter selection on the
   title page isn't the one remaining browser-default checkbox in a dark theme built
   around a custom one everywhere else. */
.toc__chapter-checkbox {
  appearance: none;
  -webkit-appearance: none;
  flex: 0 0 auto;
  width: 16px;
  height: 16px;
  margin: 0;
  position: relative;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--bg-surface);
  cursor: pointer;
  transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
}

.toc__chapter-checkbox::after {
  content: "";
  position: absolute;
  left: 4px;
  top: 1px;
  width: 4px;
  height: 8px;
  border: solid var(--bg-page);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg) scale(0);
  transition: transform 0.1s ease;
}

.toc__chapter-checkbox:checked {
  background: var(--accent-teal);
  border-color: var(--accent-teal);
}

.toc__chapter-checkbox:checked::after {
  transform: rotate(45deg) scale(1);
}

.toc__chapter-checkbox:focus-visible {
  outline: none;
  border-color: var(--accent-teal);
  box-shadow: 0 0 0 3px rgba(34, 211, 197, 0.18);
}

.toc__chapter-link {
  display: flex;
  align-items: center;
  gap: 10px;
  flex: 1;
  min-width: 0;
  text-decoration: none;
  color: inherit;
}

.toc__chapter-link:hover .toc__chapter-name {
  color: var(--accent-teal);
}

.toc__chapter-number {
  font-family: var(--font-mono);
  color: var(--text-muted);
  flex: 0 0 auto;
  min-width: 44px;
}

.toc__chapter-name {
  color: var(--text-secondary);
  flex: 1;
}

/* PR 83: tap-to-read progress (toc-tap-progress.js) - client-side only, so it starts
   absent from the row and is only ever added once that script finds a matching
   localStorage entry, same as the "N переводов" badge it sits next to. */
.toc__chapter-progress {
  flex: 0 0 auto;
  width: 44px;
  height: 4px;
  border-radius: 2px;
  background: var(--border);
  overflow: hidden;
}

.toc__chapter-progress__fill {
  display: block;
  height: 100%;
  background: var(--accent-teal);
  border-radius: 2px;
}

/* PR 83: replaces the bar once a chapter's tap-to-read progress reaches 100% - same
   teal-fill-on-dark checkmark treatment the custom checkboxes (PR 53/59/63) use for
   their own checked state, just circular instead of a checkbox's square. */
.toc__chapter-checkmark {
  flex: 0 0 auto;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--accent-teal);
  color: var(--bg-page);
  display: flex;
  align-items: center;
  justify-content: center;
}

.error-page {
  display: flex;
  flex-direction: column;
  gap: 12px;
  align-items: flex-start;
}

.error-page__status {
  font-family: var(--font-mono);
  color: var(--text-muted);
  font-size: 13px;
  letter-spacing: 1px;
}

.error-page__detail {
  font-size: 20px;
  font-weight: 700;
}

/* Reveal-on-scroll-up overlay (PR 52) - a compact duplicate of .reader-nav's controls
   (back link, heading, prev/next) that reader-scroll-nav.js slides into view over the
   chapter text on any upward scroll, so reaching them mid-chapter doesn't take a scroll
   all the way back to the top.

   PR 161: `position: fixed`, not the `sticky` this used to be - `sticky` never leaves
   normal flow, so its own box (padding + line height) was permanently reserved at the
   very top of `.main`, pushing `.reader-nav`/`.reader-content` down by that amount even
   while `transform` hid it off-screen (translateY(-100%) only moves the already-laid-out
   box, it doesn't collapse the space that box reserves) - an empty gap above the chapter
   on every page load. `fixed` never reserves flow space at all, in any visibility state.
   The tradeoff: unlike `sticky`, `fixed` doesn't automatically stay contained within
   `.main`'s own box (that containment is what made PR 52 choose `sticky` over `fixed` in
   the first place, to never overlap the sidebar) - reader-scroll-nav.js now tracks
   `.main`'s live `getBoundingClientRect()` into `left`/`width` itself instead, so it
   still lines up with `.main`'s column exactly, including through sidebar
   expand/collapse (PR 39/48/49) and viewport resizes. `transform` still handles the
   actual show/hide, layered independently on top of the position itself. */
.reader-scroll-nav {
  position: fixed;
  top: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px 20px;
  padding: 14px 32px;
  background: var(--bg-panel);
  border-bottom: 1px solid var(--border);
  transform: translateY(-100%);
  transition: transform 0.25s ease;
}

.reader-scroll-nav--visible {
  transform: translateY(0);
}

.reader-scroll-nav__heading {
  flex: 1;
  min-width: 0;
  font-size: 14px;
  font-weight: 700;
  margin: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.reader-scroll-nav .reader-nav__adjacent {
  flex: 0 0 auto;
  margin-top: 0;
}

.reader-nav {
  display: flex;
  flex-direction: column;
  gap: 8px;
  padding-bottom: 20px;
  margin-bottom: 20px;
  border-bottom: 1px solid var(--border);
}

.reader-nav__back {
  color: var(--text-muted);
  text-decoration: none;
  font-size: 13px;
}

.reader-nav__back:hover {
  color: var(--accent-teal);
}

.reader-nav__heading {
  font-size: 18px;
  font-weight: 700;
  margin: 0;
}

.reader-nav__adjacent {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  margin-top: 4px;
}

.reader-nav__adjacent a {
  color: var(--accent-teal);
  text-decoration: none;
  font-size: 13px;
}

.reader-nav__adjacent--bottom {
  max-width: var(--reader-width);
  margin: 24px auto 0;
  padding-top: 20px;
  border-top: 1px solid var(--border);
}

.reader-content {
  max-width: var(--reader-width);
  margin: 0 auto;
  color: var(--text-secondary);
  font-family: var(--reader-font-family);
  font-size: var(--reader-font-size);
  line-height: var(--reader-line-height);
}

.reader-content p {
  margin: 0 0 16px;
}

.reader-content img {
  max-width: 100%;
  height: auto;
  border-radius: 6px;
}

.reader-content hr {
  border: none;
  border-top: 1px solid var(--border);
  margin: 24px 0;
}

/* Progressive tap-to-read (PR 62) - only ever applied once tap-to-read.js has confirmed
   the visitor actually turned the setting on, so this never affects default reading. */
.reader-content__paragraph--hidden {
  display: none;
}

.reader-content__tap-hint {
  color: var(--text-muted);
  font-size: 13px;
  text-align: center;
  cursor: pointer;
}

/* PR 64: the background plate every revealed paragraph sits in - shared by both
   paragraphStyle options (readerSettings), which differ only in color/shape and whether
   .reader-content__paragraph-time (below) gets added at all. Zeroing the margin on what
   tap-to-read.js wrapped keeps the plate's own margin as the only spacing, instead of
   doubling up with .reader-content p's. */
.reader-content__paragraph-wrap {
  margin: 0 0 16px;
  padding: 12px 16px;
  border-radius: 10px;
}

.reader-content__paragraph-wrap > * {
  margin: 0;
}

.reader-content__paragraph-wrap--plain {
  background: var(--bg-surface);
  border: 1px solid var(--border);
}

/* Message-bubble look: accent-tinted instead of the neutral surface color, and an
   asymmetric corner (like the tail-less bubbles most chat apps use) instead of a plain
   rectangle - visually distinct from --plain, not just "has a timestamp, doesn't". */
.reader-content__paragraph-wrap--chat {
  background: rgba(34, 211, 197, 0.12);
  border: 1px solid rgba(34, 211, 197, 0.25);
  border-radius: 14px 14px 14px 4px;
}

.reader-content__paragraph-time {
  display: block;
  margin-top: 6px;
  font-size: 11px;
  color: var(--text-muted);
  font-family: var(--font-mono);
  text-align: right;
}

/* PR 65: reveal animations (readerSettings.paragraphAnimation) - a CSS @keyframes
   animation replays automatically the moment its element's `display` goes from `none` to
   visible, so tap-to-read.js only has to add one of these classes when it un-hides a
   wrap; no JS-driven timing needed for any of these ("typewriter" is the one option that
   isn't here, since it reveals actual text nodes over time instead - see the .js file). */
@keyframes reader-reveal-slide-up {
  from {
    opacity: 0;
    transform: translateY(18px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

@keyframes reader-reveal-slide-left {
  0% {
    opacity: 0;
    transform: translateX(-28px);
  }
  70% {
    opacity: 1;
    transform: translateX(6px);
  }
  100% {
    opacity: 1;
    transform: translateX(0);
  }
}

@keyframes reader-reveal-fade {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

@keyframes reader-reveal-blur-focus {
  from {
    opacity: 0;
    filter: blur(6px);
  }
  to {
    opacity: 1;
    filter: blur(0);
  }
}

.reader-content__paragraph-wrap--anim-slide-up {
  animation: reader-reveal-slide-up 0.35s ease both;
}

.reader-content__paragraph-wrap--anim-slide-left {
  animation: reader-reveal-slide-left 0.45s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}

.reader-content__paragraph-wrap--anim-fade {
  animation: reader-reveal-fade 0.4s ease both;
}

.reader-content__paragraph-wrap--anim-blur-focus {
  animation: reader-reveal-blur-focus 0.45s ease both;
}

/* PR 79: revealTempo word-splitting (tap-to-read.js's wrapWordsInSpans()) - shared base
   for word-by-word/line-by-line/word-dissolve/highlight-sweep, which only differ in which
   modifier class(es) they add/remove as the reveal progresses. inline-block (not the
   default inline) keeps opacity/filter transitions rendering consistently across
   browsers; the whitespace text nodes between spans are untouched, so wrapping still
   happens at the same points normal text would. */
.reader-content__word {
  display: inline-block;
}

.reader-content__word--pending {
  opacity: 0;
}

.reader-content__word--dissolved {
  opacity: 0;
  filter: blur(4px);
  transition: opacity 0.25s ease, filter 0.25s ease;
}

.reader-content__word--highlighted {
  background-color: rgba(34, 211, 197, 0.35);
  border-radius: 3px;
  transition: background-color 0.15s ease;
}

/* PR 131: the paragraph context menu (right-click on a paragraph opens Реакции/
   Комментировать - Комментировать stays a stub until PR 133). Portaled to <body> and
   positioned from the click point with inline left/top (paragraph-menu.js), the same
   reason .profile-menu__panel (PR 97) is portaled rather than nested where it's used: an
   ancestor's overflow would otherwise clip it. Same fade/no-slide-direction-needed open
   styling as that panel, just anchored at a point instead of a trigger element's rect. */
.paragraph-menu__panel {
  display: flex;
  flex-direction: column;
  gap: 2px;
  position: fixed;
  z-index: 40;
  min-width: 180px;
  padding: 6px;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: 0 10px 24px rgba(0, 0, 0, 0.35);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s ease, transform 0.15s ease;
  transform: scale(0.97);
}

.paragraph-menu__panel--open {
  opacity: 1;
  pointer-events: auto;
  transform: scale(1);
}

.paragraph-menu__item {
  display: flex;
  align-items: center;
  gap: 6px;
  width: 100%;
  padding: 8px 10px;
  border: none;
  border-radius: 6px;
  background: none;
  color: var(--text-secondary);
  font-family: inherit;
  font-size: 13px;
  text-align: left;
  text-decoration: none;
  cursor: pointer;
  transition: background-color 0.1s ease, color 0.1s ease;
}

.paragraph-menu__item:disabled {
  color: var(--text-muted);
  cursor: default;
}

@media (hover: hover) {
  .paragraph-menu__item:not(:disabled):hover {
    background: rgba(34, 211, 197, 0.12);
    color: var(--text-primary);
  }
}

/* PR 132: the "← Назад" row that swaps the two-item list for the emoji picker below, and
   back again - a plain left-aligned button styled like a lighter-weight .paragraph-menu__item. */
.paragraph-menu__back {
  align-self: flex-start;
  padding: 4px 6px;
  margin-bottom: 2px;
  border: none;
  background: none;
  color: var(--text-muted);
  font-family: inherit;
  font-size: 12px;
  cursor: pointer;
}

@media (hover: hover) {
  .paragraph-menu__back:hover {
    color: var(--text-primary);
  }
}

/* PR 132: the 10-emoji reaction picker that replaces the menu's item list once
   "Реакции" is clicked - a fixed 5-column grid, wide enough for all ten without the
   panel growing much past its usual two-item width. */
.paragraph-menu__emoji-picker {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 2px;
}

.paragraph-menu__emoji {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border: 1px solid transparent;
  border-radius: 6px;
  background: none;
  font-size: 18px;
  line-height: 1;
  cursor: pointer;
  transition: background-color 0.1s ease, border-color 0.1s ease;
}

.paragraph-menu__emoji--active {
  border-color: var(--accent-teal);
  background: rgba(34, 211, 197, 0.12);
}

@media (hover: hover) {
  .paragraph-menu__emoji:hover {
    background: rgba(34, 211, 197, 0.12);
  }
}

/* PR 132: the aggregated reaction counts rendered under a paragraph - one pill per emoji
   that has at least one reaction, nothing rendered at all for a paragraph with none (same
   "empty state = show nothing" rule PR 122's profile sections already follow).

   Two hosts, one rule each for when the strip is visible:
   - .reader-content__paragraph-wrap (tap-to-read mode, PR 62) already renders as a
     standalone plate, so the strip just sits in its normal flow, always visible, right
     under the timestamp (PR 64).
   - .paragraph-reactions-host (the ordinary reading mode - paragraph-menu.js lazily wraps
     a paragraph in one of these the first time it needs to host a strip, since the SDK's
     own raw element underneath can be anything, including a bare <img> that can't have
     children at all) shows it as a compact overlay on hover/focus, so it doesn't push
     the running text around just because a paragraph happens to have reactions. */
.paragraph-reactions {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: 6px;
}

.reader-content__paragraph-wrap > .paragraph-reactions {
  padding: 0 14px 10px;
}

.paragraph-reactions-host {
  position: relative;
  /* PR 153: unlike .reader-content__paragraph-wrap, this host doesn't zero its child's
     own margin, so the wrapped paragraph's `.reader-content p` margin-bottom (16px) was
     free to collapse against `.paragraph-comments`'s margin-top (6px) below - sibling
     margins always collapse to the larger of the two, so the toggle ended up sitting a
     fixed but unintended 16px down instead of the 6px actually specified. Zeroing the
     wrapped element's own bottom margin (below) and owning that 16px here instead keeps
     a bare host (no reactions/comments attached yet) visually identical to a plain
     paragraph, while letting .paragraph-comments's margin-top apply on its own. */
  margin-bottom: 16px;
}

.paragraph-reactions-host > :first-child {
  margin-bottom: 0;
}

/* PR 180: a zero-height positioning context of its own, inserted right after the
   paragraph's own text (paragraph-menu.js's reactionsAnchorFor()) - not the whole host,
   which .paragraph-comments (a normal-flow sibling below) would otherwise stretch,
   dragging the strip's `bottom: -8px` down along with it whenever a paragraph has both. */
.paragraph-reactions-anchor {
  position: relative;
}

.paragraph-reactions-anchor > .paragraph-reactions {
  position: absolute;
  /* Right-aligned, not left: correctly anchoring this to right after the paragraph text
     (above) puts it in the exact same row .paragraph-comments__toggle ("N комментариев")
     occupies when a paragraph has both - that button is left-aligned, so anchoring the
     strip from the right instead keeps the two from landing on the same pixels. */
  right: 0;
  bottom: -8px;
  transform: translateY(100%);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s ease;
  z-index: 1;
}

.paragraph-reactions-host:hover > .paragraph-reactions-anchor > .paragraph-reactions,
.paragraph-reactions-host:focus-within > .paragraph-reactions-anchor > .paragraph-reactions {
  opacity: 1;
  pointer-events: auto;
}

.paragraph-reactions__pill {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 2px 8px;
  border-radius: 999px;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  font-size: 12px;
  color: var(--text-secondary);
  white-space: nowrap;
}

.paragraph-reactions__pill--mine {
  border-color: var(--accent-teal);
  color: var(--text-primary);
}

/* PR 133: the "N комментариев ▾" toggle and the thread under it, both nested inside the
   same .paragraph-reactions-host/.reader-content__paragraph-wrap the reactions strip
   uses (paragraph-menu.js's paragraphHostFor()) - but unlike that strip, always visible
   in both reading modes rather than a hover/focus-only overlay. Comments are a more
   durable affordance than a decorative reaction row, so they stay in normal document
   flow instead of the reactions strip's absolute-positioned overlay treatment. */
.paragraph-comments {
  margin-top: 6px;
}

.reader-content__paragraph-wrap > .paragraph-comments {
  padding: 0 14px 10px;
}

/* PR 153: gives the comments block its own breathing room before the host's margin-bottom
   (above) closes it off, rather than butting straight up against the next paragraph the
   way it did with no bottom spacing of its own at all. */
.paragraph-reactions-host > .paragraph-comments {
  padding-bottom: 12px;
}

/* PR 158: comment text/metadata/toggles across this whole file bumped a notch (12px ->
   13px, 13px -> 14px for the body itself) - the same class of polish already done for
   the "Фильтры" panel (PR 118), and vertical gaps/margins nudged up to match rather than
   leaving the same tight spacing under now-slightly-taller lines.
   PR 166: that 13px is now 1px under --comment-font-size (settings_reading.html) rather
   than a second hardcoded number that could drift from .paragraph-comment__body's own -
   same reasoning as .paragraph-comment__meta below. */
.paragraph-comments__toggle {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 2px 0;
  border: none;
  background: none;
  color: var(--text-muted);
  font-family: inherit;
  font-size: calc(var(--comment-font-size) - 1px);
  cursor: pointer;
  transition: color 0.1s ease;
}

@media (hover: hover) {
  .paragraph-comments__toggle:hover {
    color: var(--text-primary);
  }
}

.paragraph-comments__arrow {
  font-size: 11px;
}

.paragraph-comments__list {
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin-top: 8px;
}

.paragraph-comments__list[hidden] {
  display: none;
}

/* Reddit-style nesting: each level of reply indents via its own .replies wrapper's
   margin/border, not a JS-computed depth * N inline style - depth (PR 165) only ever
   decides *whether* a level still gets this fixed per-level indent at all
   (.paragraph-comment__replies--flat below), never its actual magnitude. */
/* PR 162: spans both grid columns of the parent .paragraph-comment (its own grid item,
   auto-placed into the row below .side/.main) - without this it would auto-place into
   just the narrow left column instead, squeezing the whole reply thread into ~28px.
   margin-left is 0 now (not the old fixed 12px) so the border-left guide line sits flush
   with .paragraph-comment's own left edge - the same x-position .side already occupies
   above it, so the line visually originates from under the avatar/vote column instead of
   drifting off into the middle of it. padding-left pushes the nested reply's own content
   clear of that line, past the full width .side used to need. */
.paragraph-comment__replies {
  position: relative;
  grid-column: 1 / -1;
  display: flex;
  flex-direction: column;
  gap: 12px;
  margin: 8px 0 0 0;
  cursor: pointer;
}

/* PR 164: the guide line as its own real element (paragraph-menu.js's
   .paragraph-comment__replies-line, the first child of every .paragraph-comment__replies)
   rather than a border-left drawn directly on the container (as it used to be) - that
   older approach made `:hover` match anywhere in the flex container's full-width box
   (any reply's text, not just the line), and made a nested .replies' own hover bubble up
   and light up every ancestor's line too, since nested boxes are geometrically inside
   their parent's. A CSS pseudo-element (::before) doesn't fix this: real browsers don't
   hit-test `:hover` against a pseudo-element's own rendered box independently of its
   host - `::before:hover` behaves exactly like `:hover::before`, still keyed off the
   *host's* hover state, still the same bug. A real element gets proper independent
   :hover matching against its own box. `left`/`top` on an absolutely positioned element
   resolve against the *padding* box of its containing block - the indent below is on
   each reply's own margin-left now, not .paragraph-comment__replies' own padding-left
   like before, so this line's `left: 0` actually lands at the container's bare left edge
   (the empty gutter) instead of at the padding edge, which is exactly where the replies
   themselves already start. Every level's own line sits at a different x-offset than
   every other level's (each is offset by its own ancestors' cumulative indent), so they
   never geometrically overlap and hovering one can never register as hovering another. */
.paragraph-comment__replies-line {
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: calc(var(--side-width) + 10px);
  border-left: 1px solid var(--border);
  transition: border-left-color 0.1s ease;
}

/* PR 157: the guide line's cursor: pointer above already hints it's clickable, but
   nothing about it visually reacted to a hover before this - same border-left-color
   highlight the other collapse/reply controls already use for their own :hover (color
   instead, since there's no text here to recolor). (hover: hover) keeps it from getting
   stuck highlighted on touch, where there's no real "hover" to leave, only a tap that
   would otherwise leave the :hover state applied until the next unrelated tap.
   PR 164: targets .paragraph-comment__replies-line specifically now, not the whole
   .paragraph-comment__replies box - see that element's own comment above. */
@media (hover: hover) {
  .paragraph-comment__replies-line:hover {
    border-left-color: var(--accent-teal);
  }
}

/* PR 152: the pointer cursor above is a hover hint for the bare guide-line strip
   (paragraph-menu.js only toggles collapse when the click lands directly on the
   container, not on a nested reply) - reset back to normal over actual reply content,
   which otherwise inherits the pointer and implies the whole area is clickable.
   PR 164: margin-left (the indent past the guide line) moved here from
   .paragraph-comment__replies' own padding-left, so that container has no padding of its
   own for its guide line's `left: 0` to land inside of - see that element's own comment
   above. */
.paragraph-comment__replies > .paragraph-comment {
  cursor: auto;
  margin-left: calc(var(--side-width) + 10px);
}

/* PR 165: past MAX_INDENT_DEPTH levels (paragraph-menu.js), the fixed per-level
   margin-left above compounds into most of the comment column's width, leaving deeper
   replies' own text a few characters wide - unreadable long before any thread actually
   gets 6+ levels deep. paragraph-menu.js tags a .replies container past that depth with
   this modifier, which cancels the indent for its own children only (not its own
   position, which is still offset by every shallower ancestor) - deeper threads stop
   marching right and stay flush at the last indented level instead. The guide line
   itself (.paragraph-comment__replies-line) still renders per level and is still its own
   real, independently-hoverable element (PR 164) even where several of them now share
   the same x-offset. */
.paragraph-comment__replies--flat > .paragraph-comment {
  margin-left: 0;
}

.paragraph-comment__replies[hidden] {
  display: none;
}

/* PR 162: two-column layout (Reddit/Hacker News pattern) - a narrow left column for the
   avatar (.paragraph-comment__side) and a wide right column for everything else
   (.paragraph-comment__main). --side-width is reused by .paragraph-comment__replies
   further down so its own guide line lines up with this left column instead of the full
   comment width. PR 183: 20px, not 28px - the vote buttons that used to share this
   column with the avatar moved into .paragraph-comment__actions, so it only has to fit
   the 20x20 avatar now. */
.paragraph-comment {
  --side-width: 20px;
  display: grid;
  grid-template-columns: var(--side-width) 1fr;
  column-gap: 10px;
}

.paragraph-comment__side {
  grid-column: 1;
}

/* min-width: 0 stops long unbreakable content (e.g. a URL in the body) from forcing the
   grid wider than .paragraph-comment itself, which would otherwise squeeze the left
   column off-screen on narrow viewports instead of just wrapping. */
.paragraph-comment__main {
  grid-column: 2;
  min-width: 0;
}

/* PR 162: author + time only now - the avatar moved out into .paragraph-comment__side
   above, rather than sitting inline in this row. */
.paragraph-comment__meta {
  display: flex;
  align-items: center;
  gap: 8px;
  /* PR 166: 1px under the body's own --comment-font-size, preserving the PR 158
     proportion (13px meta vs 14px body) at every slider value instead of just at the
     default. */
  font-size: calc(var(--comment-font-size) - 1px);
}

/* PR 147: same picture-or-initials box as .sidebar__avatar (PR 88), just small enough to
   sit at the top of the narrow .paragraph-comment__side column. */
.paragraph-comment__avatar {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--accent);
  color: var(--text-primary);
  font-size: 9px;
  font-weight: 600;
  overflow: hidden;
}

.paragraph-comment__author {
  color: var(--text-primary);
  font-weight: 600;
  text-decoration: none;
}

@media (hover: hover) {
  .paragraph-comment__author:hover {
    text-decoration: underline;
  }
}

.paragraph-comment__time {
  color: var(--text-muted);
}

/* PR 172: the "edited" mark - same muted, unemphasized treatment as the timestamp it sits
   next to, not a call to attention. */
.paragraph-comment__edited {
  color: var(--text-muted);
}

/* PR 148: body_html already brings its own block structure (<p>/<br>/<ul>/<ol> from
   app/markdown_render.py) - no white-space: pre-wrap here anymore, that was only needed
   to preserve line breaks in plain textContent, and would otherwise also render the
   whitespace nh3.clean() leaves between tags. */
.paragraph-comment__body {
  margin: 6px 0;
  font-size: var(--comment-font-size);
  color: var(--text-secondary);
  overflow-wrap: break-word;
}

.paragraph-comment__body p {
  margin: 0 0 8px;
}

.paragraph-comment__body p:last-child {
  margin-bottom: 0;
}

/* PR 172: the "Комментарий удалён" placeholder delete_comment() leaves behind - muted and
   italic, the same "this isn't real content" signal .paragraph-comment__body blockquote
   already uses for quoted text below. */
.paragraph-comment__body--deleted {
  color: var(--text-muted);
  font-style: italic;
}

.paragraph-comment__body ul,
.paragraph-comment__body ol {
  margin: 0 0 8px;
  padding-left: 20px;
}

.paragraph-comment__body ul:last-child,
.paragraph-comment__body ol:last-child {
  margin-bottom: 0;
}

.paragraph-comment__body a {
  color: var(--accent-teal);
}

/* PR 156: quoted text (either the chapter paragraph itself or an earlier comment,
   inserted by paragraph-menu.js's "Цитировать" trigger) - a left rule + muted color is
   the standard "this text isn't the commenter's own words" convention, same reasoning as
   .paragraph-comment__replies' own left border marking nested structure a few rules down. */
.paragraph-comment__body blockquote {
  margin: 0 0 8px;
  padding: 2px 0 2px 10px;
  border-left: 2px solid var(--border);
  color: var(--text-muted);
}

.paragraph-comment__body blockquote:last-child {
  margin-bottom: 0;
}

.paragraph-comment__body blockquote p {
  margin: 0 0 6px;
}

.paragraph-comment__body blockquote p:last-child {
  margin-bottom: 0;
}

/* PR 150: the GIF-as-video attachment a comment can carry - rendered <video>, not <img>
   (app/gif_video.py converts it server-side), capped to the same size the composer
   itself uses so it never dwarfs the surrounding thread.

   PR 154: a plain `max-width: 240px` here was losing to .reader-content img's own
   `max-width: 100%` (PR 6) - both attachment and reader body images live under
   .reader-content, and that rule's extra type-selector specificity (0,1,1) beat this
   class-only one (0,1,0) regardless of source order, so attachments rendered at their
   natural size up to the full reading column width instead of the intended cap. Scoping
   this selector to the attachment's actual parent (0,2,0) wins outright, and a fixed
   box + object-fit: cover (rather than a max-width alone) gives every attachment - image,
   video, or gif - the same uniform thumbnail size regardless of its original dimensions,
   instead of preserving whatever aspect ratio it happened to be uploaded at.

   PR 162: scoped to .paragraph-comment__main now, not .paragraph-comment directly - the
   attachment is a grandchild since the side/main restructuring, still specific enough
   (0,2,0) to keep winning over .reader-content img's own rule. */
.paragraph-comment__main > .paragraph-comment__attachment {
  display: block;
  width: 200px;
  height: 200px;
  object-fit: cover;
  margin: 6px 0;
  border-radius: 8px;
}

/* PR 154: cursor set here (not inline via JS) because the click listener that opens the
   lightbox for these is delegated (image-lightbox.js) - comments load lazily after the
   page itself, so there's no single "attach time" a script-set inline style could hook
   into, same reasoning PR 142 already applied to .title-hero__cover. Scoped to <img>
   only: video/gif attachments share this class but shouldn't open the image lightbox. */
img.paragraph-comment__attachment {
  cursor: zoom-in;
}

/* PR 155/162/183: like/dislike on the comment itself - a compact icon+count pair in
   .paragraph-comment__actions, first in the row ahead of "Ответить" (PR 183 moved it out
   of the narrow .paragraph-comment__side vote column, which left the buttons a cramped
   tap target, worse yet under a few levels of reply indent). Not the pill/badge look
   .paragraph-reactions__pill uses for paragraph reactions (PR 132) - that strip is a
   hover-only overlay meant to stand out over running text, this is a permanent part of a
   comment's own layout, closer to Reddit/Hacker News's vote buttons. */
.paragraph-comment__reactions {
  display: flex;
  align-items: center;
  gap: 8px;
}

/* PR 162: outline SVG icon (`stroke: currentColor`, no fill at rest) + count - replaces
   the plain 👍/👎 emoji-and-number text this used to be. The dislike button reuses the
   exact same "thumbs up" SVG path (paragraph-menu.js's THUMB_ICON) flipped vertically
   below, rather than carrying a second, mirrored path. */
/* PR 183: icon beside the count, not above it - .paragraph-comment__actions is a
   horizontal row of same-height buttons now, not the narrow vertical vote column this
   used to sit in (PR 162). */
.paragraph-comment__reaction {
  display: flex;
  align-items: center;
  gap: 3px;
  padding: 3px;
  border: none;
  background: none;
  color: var(--text-muted);
  font-family: inherit;
  font-size: 10px;
  line-height: 1;
  cursor: pointer;
  transition: color 0.1s ease;
}

.paragraph-comment__reaction--down .paragraph-comment__reaction-icon svg {
  transform: scaleY(-1);
}

@media (hover: hover) {
  .paragraph-comment__reaction:hover {
    color: var(--accent-teal);
  }
}

/* Same "active pick" question as .paragraph-reactions__pill--mine (PR 132) answers for
   paragraph reactions, just a different presentation: the outline stays teal (`color`,
   inherited by the SVG's `stroke: currentColor`) and the icon itself gets a solid fill -
   replaces PR 155's original "just recolor the text" treatment now that there's an icon
   to fill instead of only a number to recolor. */
.paragraph-comment__reaction--mine {
  color: var(--accent-teal);
}

.paragraph-comment__reaction--mine .paragraph-comment__reaction-icon svg {
  fill: var(--text-primary);
}

/* PR 181: the whole "Ответить"/"Изменить"/"Удалить" row as one flex container instead
   of each button setting its own margin-left - a shared gap guarantees spacing between
   every pair of buttons, rather than relying on each one to remember to add its own (see
   this PR's own diagnosis: .reply-toggle never did, which is exactly how it ended up
   glued to "Удалить"). .paragraph-comment__collapse-toggle (PR 152) used to be this row's
   own last child too, but PR 182 moved it into .paragraph-comment__meta instead - it's a
   thread-level action, not a per-comment one like the three that stayed here. */
.paragraph-comment__actions {
  display: inline-flex;
  align-items: center;
  gap: 10px;
}

.paragraph-comment__reply-toggle {
  padding: 0;
  border: none;
  background: none;
  color: var(--text-muted);
  font-family: inherit;
  font-size: calc(var(--comment-font-size) - 1px);
  text-decoration: none;
  cursor: pointer;
  transition: color 0.1s ease;
}

@media (hover: hover) {
  .paragraph-comment__reply-toggle:hover {
    color: var(--accent-teal);
  }
}

/* PR 172: "Изменить"/"Удалить" on the visitor's own comment - same ghost-text treatment
   as .paragraph-comment__reply-toggle (they sit in the same row, PR 181's .actions). */
.paragraph-comment__edit-toggle,
.paragraph-comment__delete-toggle {
  padding: 0;
  border: none;
  background: none;
  color: var(--text-muted);
  font-family: inherit;
  font-size: calc(var(--comment-font-size) - 1px);
  cursor: pointer;
  transition: color 0.1s ease;
}

@media (hover: hover) {
  .paragraph-comment__edit-toggle:hover {
    color: var(--accent-teal);
  }

  /* A destructive action gets the same warning color .downloads-history__delete already
     uses for one, not teal like every other hover here. */
  .paragraph-comment__delete-toggle:hover {
    color: var(--accent-orange);
  }
}

/* PR 152: the "[–]"/"[+]" collapse trigger - same ghost-text treatment as
   .paragraph-comment__reply-toggle. PR 182 moved it into .paragraph-comment__meta
   (next to the author/timestamp) instead of the actions row below - no margin-left of
   its own needed here any more, meta's own `gap` already spaces every child evenly,
   this one included. */
.paragraph-comment__collapse-toggle {
  padding: 0;
  border: none;
  background: none;
  color: var(--text-muted);
  font-family: inherit;
  font-size: calc(var(--comment-font-size) - 1px);
  cursor: pointer;
  transition: color 0.1s ease;
}

@media (hover: hover) {
  .paragraph-comment__collapse-toggle:hover {
    color: var(--accent-teal);
  }
}

/* PR 133: the composer shared by the menu's "Комментировать" item and every comment's
   own "Ответить" - a plain textarea + .btn.btn--sm, sized to fit inside the same compact
   .paragraph-menu__panel the reaction picker replaces (paragraph-menu.js's
   buildComposer()), as well as inline under a comment when used as a reply form. */
.paragraph-comments__composer {
  display: flex;
  flex-direction: column;
  gap: 6px;
  width: 240px;
  max-width: 100%;
  margin-top: 6px;
}

.paragraph-comments__composer[hidden] {
  display: none;
}

.paragraph-comments__textarea {
  width: 100%;
  min-height: 64px;
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-page);
  color: var(--text-primary);
  font-family: inherit;
  font-size: 13px;
  resize: vertical;
}

.paragraph-comments__textarea:focus-visible {
  outline: none;
  border-color: var(--accent-teal);
}

/* PR 149: the trigger buttons + "Отправить" row - submit used to be the composer's only
   other child, right-aligned via `.btn { align-self: flex-end }`; now that it shares a
   row with the trigger buttons (PR 149's emoji picker, PR 150's GIF picker), alignment
   comes from this row's own justify-content instead. */
.paragraph-comments__toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}

/* PR 150/151: groups the emoji/attachment trigger buttons as one flex item on the
   toolbar's left side, so .paragraph-comments__toolbar itself still only ever has two
   children (this group, submit) regardless of how many triggers end up in it. */
.paragraph-comments__triggers {
  display: flex;
  align-items: center;
  gap: 4px;
}

.paragraph-comments__emoji-toggle,
.paragraph-comments__attachment-toggle {
  flex: 0 0 auto;
  padding: 4px 6px;
  border: 1px solid transparent;
  border-radius: 6px;
  background: none;
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
  transition: background-color 0.1s ease, border-color 0.1s ease;
}

@media (hover: hover) {
  .paragraph-comments__emoji-toggle:hover,
  .paragraph-comments__attachment-toggle:hover {
    background: rgba(34, 211, 197, 0.12);
  }
}

/* PR 150/151: the staged-file chip shown once an image/video/GIF is picked, before
   "Отправить" is clicked - same reasoning as the PDF-export precedent
   (app/services/exports.py) for never promising an action the visitor hasn't actually
   confirmed yet. */
.paragraph-comments__attachment-chip {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px 8px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-page);
  font-size: 12px;
}

/* PR 151: a real thumbnail for a staged image (GIF included) - stageAttachment() only
   creates this element for an image/* file, a video's chip has just the name/remove. */
.paragraph-comments__attachment-chip-thumb {
  flex: 0 0 auto;
  width: 28px;
  height: 28px;
  object-fit: cover;
  border-radius: 4px;
}

.paragraph-comments__attachment-chip-name {
  flex: 1 1 auto;
  overflow: hidden;
  color: var(--text-secondary);
  text-overflow: ellipsis;
  white-space: nowrap;
}

.paragraph-comments__attachment-chip-remove {
  flex: 0 0 auto;
  padding: 0 4px;
  border: none;
  background: none;
  color: var(--text-muted);
  font-size: 14px;
  line-height: 1;
  cursor: pointer;
}

@media (hover: hover) {
  .paragraph-comments__attachment-chip-remove:hover {
    color: var(--text-primary);
  }
}

/* PR 149: the free-insertion emoji picker reuses .paragraph-menu__panel/__emoji-picker/
   __emoji (PR 131/132) wholesale for its look/open-close animation - this just widens the
   grid from 5 to 8 columns, since its emoji set is much larger than the 10-item reaction
   picker's and would otherwise grow tall instead of wide. */
.comment-emoji-picker__grid {
  grid-template-columns: repeat(8, 1fr);
}

/* PR 148: syntax hint for the subset app/markdown_render.py actually renders. */
.paragraph-comments__hint {
  margin: 0;
  font-size: 11px;
  color: var(--text-muted);
}

/* PR 89: /settings split into left-nav tabs (Чтение/Аккаунт/Безопасность) under their
   own sub-routes, replacing the single flat page from PR 61. Same fixed-width sticky
   sidebar language as .catalog-filters (PR 85), mirrored to the left since nothing here
   competes for that side. */
.settings-layout {
  display: flex;
  align-items: flex-start;
  gap: 24px;
  margin-top: 20px;
}

.settings-nav {
  flex: 0 0 200px;
  position: sticky;
  top: 16px;
  display: flex;
  flex-direction: column;
  gap: 4px;
}

.settings-nav__link {
  padding: 10px 14px;
  border-radius: 8px;
  font-size: 14px;
  color: var(--text-secondary);
  text-decoration: none;
  transition: background-color 0.15s ease, color 0.15s ease;
}

@media (hover: hover) {
  .settings-nav__link:hover {
    color: var(--accent-teal);
    background-color: rgba(34, 211, 197, 0.08);
  }
}

/* PR 138: every other interactive control on the site gets a teal focus-visible ring
   (.auth-form__input, .toc__export-format, the checkboxes, ...) - this link and
   .settings-back below fell through the cracks and kept the bare browser-default outline
   until now. */
.settings-nav__link:focus-visible {
  outline: none;
  box-shadow: 0 0 0 3px rgba(34, 211, 197, 0.18);
}

.settings-nav__link--active {
  color: var(--accent-teal);
  background-color: rgba(34, 211, 197, 0.12);
  font-weight: 600;
}

.settings-content {
  flex: 1;
  min-width: 0;
}

/* PR 104: only ever shown by settings-mobile-nav.js, on mobile, in "detail" state - starts
   `hidden` (settings_layout.html) so desktop and no-JS mobile visitors never see a back
   link with nothing useful to go back to (the nav column/tab row is already right there). */
.settings-back {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin-bottom: 16px;
  color: var(--text-muted);
  text-decoration: none;
  font-size: 13px;
  transition: color 0.15s ease;
}

.settings-back[hidden] {
  display: none;
}

@media (hover: hover) {
  .settings-back:hover {
    color: var(--accent-teal);
  }
}

.settings-back:focus-visible {
  outline: none;
  color: var(--accent-teal);
  box-shadow: 0 0 0 3px rgba(34, 211, 197, 0.18);
  border-radius: 4px;
}

/* PR 138: /settings/account and /settings/security used to render each block (avatar,
   profile fields, privacy toggles, password change) as a bare, boundary-less stack of
   labels and inputs straight on the page background - no visual grouping at all, unlike
   the panelled look .reader-settings__panel already gives the reading-settings page.
   .settings-card gives those two pages the same panel language (same
   background/border/radius as .reader-settings__panel and .catalog-filters) rather than
   inventing a new one. Verified against the claude_design MCP project referenced
   elsewhere in this file for a more developed settings concept first (see the PR 138
   commit) - the MCP connection wasn't available in this session (no /design-login
   consent granted), so this card treatment is a manual, point-in-time polish pass per
   CLAUDE.md's documented fallback, not a Claude Design import; a real import should
   still replace it if/when the concept becomes available.

   Deliberately NOT applied to settings_reading.html - that page's fields already sit
   inside .reader-settings__panel per section, so wrapping the whole section in a second,
   outer card would just nest one box inside another instead of adding hierarchy. */
.settings-card {
  max-width: 640px;
  margin-top: 28px;
  padding: 24px;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 12px;
}

.settings-card:first-of-type {
  margin-top: 20px;
}

/* Fields inside a card fill its full width instead of stopping at .settings-form's own
   420px cap (still in effect on register_avatar.html, the one page reusing .settings-form
   outside a .settings-card) - a wide single-column field reads closer to the GitHub
   Settings reference PR 89 already cites than a narrow column floating inside a much
   wider card. */
.settings-card .settings-form {
  max-width: none;
}

.reader-settings-page {
  max-width: 480px;
}

/* Named sections (PR 61) - .reader-settings-page used to render every field as one flat
   list, which stopped scaling as soon as a second, unrelated group of settings (the
   upcoming "Чтение с помощью тапа" section, PR 63-65) needed a place to live. Same
   section-heading look as .downloads-section/.downloads-section__title elsewhere in this
   file, reused rather than invented fresh.

   Also reused as-is (PR 138) for every .settings-card heading on the account/security
   pages - one shared title style for "the name of this group of fields" everywhere in
   /settings/*, sized/colored a step above .auth-form__label so the hierarchy between a
   card's own title and its fields' labels reads clearly instead of both looking like the
   same weight of text. */
.reader-settings-section {
  margin-top: 28px;
}

.reader-settings-section:first-of-type {
  margin-top: 0;
}

.reader-settings-section__title {
  font-size: 15px;
  font-weight: 700;
  color: var(--text-primary);
  margin: 0 0 12px;
}

.reader-settings__panel {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  margin-top: 10px;
  padding: 14px;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
}

.reader-settings__field {
  display: flex;
  flex-direction: column;
  gap: 4px;
  font-size: 12px;
  color: var(--text-muted);
}

.reader-settings__field input[type="range"] {
  accent-color: var(--accent-teal);
  width: 100%;
}

/* PR 63: the tap-to-read switch is a checkbox, not a text/range/select field like the
   rest of .reader-settings__field - row layout (box next to its label) instead of the
   stacked "label above control" the others use. */
.reader-settings__field--checkbox {
  flex-direction: row;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--text-secondary);
  cursor: pointer;
}

/* Same custom checkmark PR 53/59 already gave the catalog genre and chapter-selection
   checkboxes - kept as its own class (not reusing theirs) since neither of those names
   fits a general-purpose settings toggle. */
.reader-settings__toggle {
  appearance: none;
  -webkit-appearance: none;
  flex: 0 0 auto;
  width: 16px;
  height: 16px;
  margin: 0;
  position: relative;
  border: 1px solid var(--border);
  border-radius: 4px;
  background: var(--bg-surface);
  cursor: pointer;
  transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
}

.reader-settings__toggle::after {
  content: "";
  position: absolute;
  left: 4px;
  top: 1px;
  width: 4px;
  height: 8px;
  border: solid var(--bg-page);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg) scale(0);
  transition: transform 0.1s ease;
}

.reader-settings__toggle:checked {
  background: var(--accent-teal);
  border-color: var(--accent-teal);
}

.reader-settings__toggle:checked::after {
  transform: rotate(45deg) scale(1);
}

.reader-settings__toggle:focus-visible {
  outline: none;
  border-color: var(--accent-teal);
  box-shadow: 0 0 0 3px rgba(34, 211, 197, 0.18);
}

/* Reading-speed test (PR 77) - nested inside the "Чтение с помощью тапа" section rather
   than getting its own top-level .reader-settings-section, since it's a sub-feature of
   tap-to-read (its result only matters to PR 79's tempo animations, also scoped there). */
.reader-settings-subsection__title {
  font-size: 13px;
  font-weight: 700;
  color: var(--text-secondary);
  margin: 20px 0 8px;
}

.reading-speed-test__status {
  color: var(--text-muted);
  font-size: 13px;
  margin: 10px 0 0;
}

.reading-speed-test__sample {
  margin: 10px 0 0;
  color: var(--text-secondary);
  line-height: 1.8;
}

.reading-speed-test__word {
  border-radius: 3px;
  cursor: pointer;
  transition: background-color 0.15s ease;
}

@media (hover: hover) {
  .reading-speed-test__word:hover {
    background-color: rgba(34, 211, 197, 0.18);
  }
}

/* PR 125: the test itself runs inside a modal now, rather than unfolding directly in the
   settings page's own flow - same fixed-overlay/backdrop-click/close-button pattern as
   .title-quickview-modal (PR 117), just with its markup already in the page rather than
   fetched, since there's no server round-trip involved here. */
.reading-speed-modal {
  position: fixed;
  inset: 0;
  z-index: 90;
  display: none;
  align-items: center;
  justify-content: center;
  padding: 20px;
  background: rgba(0, 0, 0, 0.6);
}

.reading-speed-modal--open {
  display: flex;
}

.reading-speed-modal__panel {
  position: relative;
  width: 100%;
  max-width: 560px;
  max-height: 85vh;
  overflow-y: auto;
  padding: 24px;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  box-shadow: 0 20px 48px rgba(0, 0, 0, 0.4);
}

.reading-speed-modal__close {
  position: absolute;
  top: 12px;
  right: 12px;
  background: none;
  border: none;
  padding: 4px;
  color: var(--text-muted);
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
  transition: color 0.15s ease;
}

@media (hover: hover) {
  .reading-speed-modal__close:hover {
    color: var(--accent-teal);
  }
}

/* The countdown - deliberately a small badge in the panel's corner, not a large overlay
   on top of the sample text (see the roadmap note this PR came from: unobtrusive, not
   distracting from actually reading). */
.reading-speed-modal__timer {
  position: absolute;
  top: 12px;
  left: 24px;
  padding: 3px 9px;
  border-radius: 999px;
  background: var(--bg-page);
  border: 1px solid var(--border);
  color: var(--text-muted);
  font-size: 12px;
  font-variant-numeric: tabular-nums;
}

/* PR 78: manual readingSpeedWpm entry, independent of the test above - same field-label
   layout as the rest of .reader-settings__field, just its own input instead of a
   <select>/<input type="range"> since a typed number is the whole point here. */
.reading-speed-test__manual-field {
  margin-top: 14px;
  max-width: 200px;
}

/* PR 94: input flanked by custom +/- buttons instead of the native spinner. */
.reading-speed-test__stepper {
  display: flex;
  align-items: stretch;
}

.reading-speed-test__manual-field input {
  height: 36px;
  min-width: 0;
  flex: 1 1 auto;
  padding: 0 8px;
  border: 1px solid var(--border);
  border-left: none;
  border-right: none;
  background: var(--bg-surface);
  color: var(--text-primary);
  font-family: var(--font-mono);
  font-size: 13px;
  text-align: center;
  /* The native up/down spinner can't be restyled in any engine (same limitation PR 54 hit
     with <select>) - hidden here, replaced with the custom +/- buttons flanking it. */
  -moz-appearance: textfield;
}

.reading-speed-test__manual-field input::-webkit-outer-spin-button,
.reading-speed-test__manual-field input::-webkit-inner-spin-button {
  -webkit-appearance: none;
  margin: 0;
}

.reading-speed-test__manual-field input::placeholder {
  color: var(--text-muted);
}

.reading-speed-test__stepper-btn {
  flex: 0 0 auto;
  width: 32px;
  height: 36px;
  border: 1px solid var(--border);
  background: var(--bg-surface);
  color: var(--text-secondary);
  font-family: var(--font-mono);
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
}

.reading-speed-test__stepper-btn:first-child {
  border-radius: 8px 0 0 8px;
}

.reading-speed-test__stepper-btn:last-child {
  border-radius: 0 8px 8px 0;
}

.reading-speed-test__stepper-btn:focus-visible {
  position: relative;
  z-index: 1;
  outline: none;
  border-color: var(--accent-teal);
  box-shadow: 0 0 0 3px rgba(34, 211, 197, 0.18);
}

@media (hover: hover) {
  .reading-speed-test__stepper-btn:hover {
    color: var(--accent-teal);
    border-color: var(--accent-teal);
  }
}

.reading-speed-test__manual-field input:focus-visible {
  border-color: var(--accent-teal);
  box-shadow: 0 0 0 3px rgba(34, 211, 197, 0.18);
  outline: none;
}

.reader-settings-preview {
  margin-top: 16px;
  padding: 16px;
  /* Bug fix: only the inner <p> was capped at --reader-width - the panel itself grew to
     fill the settings column regardless, so the visible background/border rectangle
     never actually matched the width the "Ширина области чтения" slider (PR 34) claims
     to be previewing. */
  max-width: var(--reader-width);
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  color: var(--text-secondary);
  font-family: var(--reader-font-family);
  font-size: var(--reader-font-size);
  line-height: var(--reader-line-height);
}

.reader-settings-preview p {
  max-width: var(--reader-width);
  margin: 0;
}

.reader-export {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 8px;
}

.reader-export__label {
  font-size: 12px;
  color: var(--text-muted);
}

.reader-export__link {
  display: inline-block;
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 6px 14px;
  font-size: 12px;
  color: var(--accent-teal);
  text-decoration: none;
  transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
}

.reader-export__link:hover {
  border-color: var(--accent-teal);
}

@keyframes translation-choice-in {
  from {
    opacity: 0;
    transform: translateY(-8px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.translation-choice {
  display: flex;
  flex-direction: column;
  gap: 14px;
  max-width: 640px;
  animation: translation-choice-in 0.2s ease;
}

.translation-choice__back {
  color: var(--text-muted);
  text-decoration: none;
  font-size: 13px;
}

.translation-choice__back:hover {
  color: var(--accent-teal);
}

.translation-choice__heading {
  font-size: 20px;
  font-weight: 800;
  margin: 0;
}

.translation-choice__hint {
  font-size: 13px;
  color: var(--text-secondary);
  margin: 0;
}

.translation-choice__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.translation-choice__link {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 10px;
  padding: 14px 16px;
  text-decoration: none;
  color: inherit;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
  transition: border-color 0.15s ease, transform 0.15s ease, box-shadow 0.15s ease;
}

.translation-choice__team {
  font-size: 14px;
  font-weight: 700;
}

.translation-choice__date {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--text-muted);
  flex: 0 0 auto;
}

.download-status {
  display: flex;
  flex-direction: column;
  gap: 14px;
  max-width: 480px;
}

.download-status__heading {
  font-size: 20px;
  font-weight: 800;
  margin: 0;
}

.download-status__text {
  font-size: 14px;
  color: var(--text-secondary);
  margin: 0;
}

.download-status__bar {
  height: 3px;
  background: var(--border);
  border-radius: 2px;
  overflow: hidden;
}

.download-status__bar-fill {
  height: 100%;
  width: 0%;
  background: var(--accent-teal);
  transition: width 0.3s ease;
}

.download-status__text--error {
  color: var(--accent-orange);
}

.download-status__eta {
  font-size: 12px;
  color: var(--text-muted);
  margin: 0;
}

.download-status__ambiguous {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 6px;
  font-size: 13px;
}

.download-status__ambiguous-item {
  padding: 8px 10px;
  border: 1px solid var(--border);
  border-radius: 6px;
  background: var(--bg-surface);
}

.download-status__ambiguous-chapter {
  display: block;
  font-weight: 700;
  color: var(--text-secondary);
  margin-bottom: 2px;
}

.download-status__ambiguous-branches {
  color: var(--text-muted);
}

.download-status__retry {
  display: flex;
  align-items: center;
  gap: 10px;
}

.downloads-section {
  margin-top: 28px;
}

.downloads-section:first-of-type {
  margin-top: 20px;
}

.downloads-section__title {
  font-size: 14px;
  font-weight: 700;
  color: var(--text-secondary);
  margin: 0 0 12px;
}

.downloads-list,
.downloads-history {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
}

.downloads-list__item,
.downloads-history__item {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 12px 0;
  border-bottom: 1px solid var(--border);
  font-size: 13px;
}

.downloads-list__link,
.downloads-history__link {
  flex: 1;
  min-width: 0;
  color: var(--text-primary);
  font-weight: 600;
  text-decoration: none;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.downloads-list__link:hover,
.downloads-history__link:hover {
  color: var(--accent-teal);
}

.downloads-list__fmt,
.downloads-history__fmt {
  flex: 0 0 auto;
  font-family: var(--font-mono);
  color: var(--text-muted);
}

.downloads-list__item .download-status__bar {
  flex: 1;
  max-width: 120px;
}

.downloads-list__status {
  flex: 0 0 auto;
  color: var(--text-secondary);
}

.downloads-list__eta {
  flex: 0 0 auto;
  color: var(--text-muted);
  font-size: 12px;
}

.downloads-history__status {
  flex: 0 0 auto;
  color: var(--text-secondary);
}

.downloads-history__status--error {
  color: var(--accent-orange);
}

.downloads-history__date {
  flex: 0 0 auto;
  font-family: var(--font-mono);
  color: var(--text-muted);
  font-size: 12px;
}

.downloads-history__download {
  flex: 0 0 auto;
}

.downloads-history__delete {
  flex: 0 0 auto;
  background: none;
  border: none;
  padding: 4px;
  color: var(--text-muted);
  font-size: 18px;
  line-height: 1;
  cursor: pointer;
  transition: color 0.15s ease;
}

.downloads-history__delete:disabled {
  opacity: 0.5;
  cursor: default;
}

@media (hover: hover) {
  .downloads-history__delete:hover:not(:disabled) {
    color: var(--accent-orange);
  }
}

.activity-stats {
  display: flex;
  gap: 32px;
  margin: 20px 0 8px;
}

.activity-stats__item {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

.activity-stats__value {
  font-size: 20px;
  font-weight: 800;
}

.activity-stats__value--accent {
  color: var(--accent-teal);
}

.activity-stats__label {
  font-size: 11px;
  color: var(--text-muted);
  text-transform: uppercase;
  letter-spacing: 0.5px;
}

/* Mobile breakpoint — layout adapted from the "Mobile Home" / "Mobile Title Page" /
   "Mobile Reader" / "Mobile Downloads" frames in the Claude Design import (see
   CLAUDE.md, "Дизайн"). Sidebar becomes a bottom tab bar, matching those frames;
   account controls move to a slim fixed top strip, which isn't covered by any of
   the mobile frames, extended here from the same visual language. */
@media (max-width: 640px) {
  /* PR 46: switching sidebar section here is a full page navigation (server-rendered,
     no client-side router), not a single-page-app transition - so "animating between
     sections" means fading/sliding each newly-loaded page's content in on paint,
     instead of the previous hard cut. Kept short (150ms) so it doesn't read as added
     load time, and scoped to this mobile breakpoint only - the desktop sidebar already
     stays put alongside .main rather than being replaced by page navigation the way the
     mobile bottom tab bar visually reads as "switching sections", so it doesn't need
     this treatment. */
  @keyframes mobile-section-in {
    from {
      opacity: 0;
      transform: translateY(6px);
    }
    to {
      opacity: 1;
      transform: translateY(0);
    }
  }

  .main {
    padding-top: var(--mobile-account-height);
    padding-bottom: 72px;
    animation: mobile-section-in 0.15s ease-out;
  }

  /* PR 47: /library and /library/catalog's own content already fades in via
     .library-tabs-content - without this, .main's animation above would run at the same
     time, and since CSS opacity compounds across nested animated elements, the two
     together would read as one slower, muddier fade instead of either intended one. */
  .main:has(.library-tabs-content) {
    animation: none;
  }

  .sidebar {
    position: fixed;
    top: auto;
    bottom: 0;
    left: 0;
    right: 0;
    width: 100%;
    height: 64px;
    flex-direction: row;
    align-items: center;
    justify-content: space-around;
    gap: 0;
    padding: 0;
    border-right: none;
    border-top: 1px solid var(--border);
    z-index: 40;
  }

  /* Scoped to direct children (like the desktop 44x44 hit-box rule above) - PR 71: an
     unscoped .sidebar__link here also matched the logout button and the anonymous
     login/register links inside .sidebar__account, which aren't part of this bottom tab
     bar at all. Their inherited min-height: 44px/padding: 12px inflated the fixed
     top account strip's real height well past .main's hardcoded padding-top, so page
     content started underneath it. */
  .sidebar > .sidebar__link {
    font-size: 10px;
    gap: 0;
    padding: 12px;
    min-width: 44px;
    min-height: 44px;
    justify-content: center;
  }

  .sidebar__toggle {
    display: none;
  }

  .sidebar__account {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    margin-top: 0;
    width: auto;
    /* Pinned to the same value .main's padding-top compensates for (PR 71) - box-sizing
       makes padding count toward this, so whatever the actual content measures out to,
       the strip itself never grows past what .main already leaves room for underneath
       it - the smaller .sidebar__avatar size below is chosen to fit this exactly
       (10px + 28px + 10px = 48px). */
    min-height: var(--mobile-account-height);
    box-sizing: border-box;
    flex-direction: row;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 10px 16px;
    border-top: none;
    border-bottom: 1px solid var(--border);
    background: var(--bg-panel);
    z-index: 40;
  }

  .sidebar__avatar {
    width: 28px;
    height: 28px;
    font-size: 11px;
  }

  .header {
    padding: 14px 16px;
    flex-wrap: wrap;
  }

  .content {
    padding: 16px;
  }

  .library-tabs {
    padding: 0 16px;
  }

  .content__hint--page {
    padding: 0 16px;
  }

  /* PR 85: the sidebar layout only works with room for a fixed-width column beside the
     grid - below the breakpoint .catalog-layout__results (the grid) is the only child
     left in flow, so this just stacks it full-width; .catalog-filters itself is pulled
     out of flow entirely below (PR 103). */
  .catalog-layout {
    flex-direction: column;
  }

  /* PR 103 (пересматривает PR 85's mobile order: -1 stacking, kept until now): a filters
     panel merely stacked above the grid still pushed a page of cards down below the fold
     on a phone-width viewport, especially once the genre list (50+ checkboxes, PR 38) is
     expanded - a fullscreen takeover reads more like a normal e-commerce filter sheet and
     needs no in-flow space of its own. The panel's own opaque background (inherited from
     the base rule above) doubles as the backdrop - no separate overlay element needed.
     catalog-filters-toggle.js/catalog-filters-accordion.js are unchanged: they only ever
     toggle `hidden`/`--closing`, oblivious to how those states are styled at this width. */
  .catalog-filters {
    position: fixed;
    inset: 0;
    z-index: 70;
    width: 100%;
    max-height: none;
    border: none;
    border-radius: 0;
    padding: 20px 16px;
    animation: catalog-filters-in-mobile 0.22s ease-out;
  }

  /* Slides from the right instead of the desktop entrance's fade-and-drop-down (see
     .catalog-filters-in above) - a fullscreen sheet reads as "sliding into place" rather
     than "fading in", per the roadmap's "выезжающая анимацией справа налево". */
  @keyframes catalog-filters-in-mobile {
    from {
      opacity: 0;
      transform: translateX(100%);
    }
    to {
      opacity: 1;
      transform: translateX(0);
    }
  }

  /* Overrides the desktop --closing transform (translateY) with the matching reverse of
     the slide-in above - same class, same JS-driven transition timing (0.15s, unchanged
     in catalog-filters-toggle.js), just a different direction at this width. */
  .catalog-filters--closing {
    transform: translateX(100%);
  }

  /* PR 89: same stack-instead-of-column reasoning as .catalog-layout above - a fixed
     200px nav column has no room next to the content pane on a phone-width viewport, and
     a short 3-item nav has no scrolling behavior worth keeping sticky. Turned into a
     horizontal row (not a vertical stack) since 3 short labels fit one line and read more
     like tabs than a menu at this width. */
  .settings-layout {
    flex-direction: column;
  }

  .settings-nav {
    flex-direction: row;
    flex-basis: auto;
    position: static;
    gap: 8px;
    overflow-x: auto;
  }

  .settings-nav__link {
    flex: 0 0 auto;
    white-space: nowrap;
  }

  /* PR 104: settings-mobile-nav.js's progressive enhancement over the plain horizontal
     tab row above - once it confirms this breakpoint and there's JS to drive the two
     states, --mobile-js turns the nav back into a full vertical list (this now *is* the
     "choose a section" screen, not a strip permanently pinned above the content), and
     --mobile-detail (set right before following a nav link) swaps to showing just the
     chosen section's content plus the back link instead. */
  .settings-layout--mobile-js .settings-nav {
    flex-direction: column;
    overflow-x: visible;
    gap: 6px;
  }

  .settings-layout--mobile-js .settings-nav__link {
    white-space: normal;
    padding: 14px 16px;
    border: 1px solid var(--border);
    border-radius: 8px;
  }

  .settings-layout--mobile-js:not(.settings-layout--mobile-detail) .settings-content {
    display: none;
  }

  .settings-layout--mobile-js.settings-layout--mobile-detail .settings-nav {
    display: none;
  }

  .settings-card {
    max-width: none;
    padding: 18px 16px;
  }

  .search-form {
    max-width: none;
    flex-wrap: wrap;
  }

  .search-input {
    flex: 1 1 100%;
  }

  .title-card-grid {
    grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
    gap: 10px;
  }

  .title-card__name {
    font-size: 11px;
  }

  .locked-feature {
    padding: 32px 20px;
  }

  .title-hero {
    gap: 14px;
    padding-bottom: 16px;
  }

  .title-hero__cover {
    width: 100px;
    height: 148px;
    flex: 0 0 100px;
  }

  .title-hero__name {
    font-size: 22px;
    letter-spacing: -0.3px;
    line-height: 1.15;
  }

  .title-hero__actions {
    flex-wrap: wrap;
  }

  .title-hero__actions form {
    display: flex;
    flex: 1 1 100%;
    gap: 8px;
  }

  .toc__volume-header {
    flex-wrap: wrap;
  }

  .toc {
    /* Wider clearance than desktop - the panel spans full width down here (below) and
       needs room above the fixed bottom tab bar too. */
    padding-bottom: 132px;
  }

  .toc__export-panel {
    /* Full-width bar above the fixed bottom tab bar (PR 46, 64px tall) instead of a
       corner-anchored box - same 76px clearance already used by the download-ready toast
       (PR 50) for the same tab bar. */
    bottom: 76px;
    left: 12px;
    right: 12px;
  }

  .toc__export {
    justify-content: center;
  }

  .downloads-list__item,
  .downloads-history__item {
    flex-wrap: wrap;
    gap: 6px 10px;
  }

  .downloads-list__link,
  .downloads-history__link {
    flex: 1 1 100%;
    white-space: normal;
  }

  .downloads-list__item .download-status__bar {
    flex: 1 1 100%;
    max-width: none;
    order: 3;
  }

  .activity-stats {
    gap: 16px;
    flex-wrap: wrap;
  }

  .activity-stats__item {
    flex: 1 1 30%;
    min-width: 90px;
  }

  .activity-stats__value {
    font-size: 17px;
  }

  .reader-nav__adjacent {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    margin: 0;
    padding: 14px 20px;
    background: var(--bg-panel);
    border-top: 1px solid var(--border);
    justify-content: space-between;
    z-index: 40;
  }

  /* The top instance is already pinned to the bottom of the viewport above (immersive
     reader mode, PR 19) - a second copy after the chapter content would just duplicate
     it on screen at once, so it's mobile-only redundant here. */
  .reader-nav__adjacent--bottom {
    display: none;
  }

  /* PR 72: the reveal-on-scroll-up overlay (PR 52) adds more on top of an already
     simplified mobile reader (PR 19) - chapter navigation is already always reachable
     here via the fixed bottom bar (.reader-nav__adjacent above) and the in-flow top/
     bottom nav, so a second, separate popup panel competing for the same space is more
     clutter than convenience. reader-scroll-nav.js's own scroll listener still runs and
     toggles --visible, harmlessly, since there's nothing left for it to reveal. */
  .reader-scroll-nav {
    display: none;
  }

  body.reader-page .main {
    padding-top: 0;
    padding-bottom: 56px;
  }

  body.reader-page .sidebar,
  body.reader-page .sidebar__account {
    display: none;
  }
}

/* Global "file ready" toasts (PR 50) - rendered by download-ready.js from
   GET /downloads/ready, which is polled on every logged-in page so a finished title
   download gets delivered wherever the visitor ends up, not only on its own job page. */
.download-ready {
  position: fixed;
  top: 16px;
  right: 16px;
  z-index: 60;
  display: flex;
  flex-direction: column;
  gap: 8px;
  max-width: 320px;
}

.download-ready[hidden] {
  display: none;
}

@keyframes download-ready-in {
  from {
    opacity: 0;
    transform: translateY(-8px);
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

.download-ready__toast {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 12px;
  background: var(--bg-surface);
  border: 1px solid var(--border);
  border-radius: 8px;
  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35);
  animation: download-ready-in 0.2s ease;
}

.download-ready__text {
  flex: 1;
  font-size: 13px;
  color: var(--text-secondary);
}

.download-ready__dismiss {
  background: none;
  border: none;
  padding: 4px;
  color: var(--text-muted);
  font-size: 18px;
  line-height: 1;
  cursor: pointer;
  transition: color 0.15s ease;
}

@media (hover: hover) {
  .download-ready__dismiss:hover {
    color: var(--accent-teal);
  }
}

@media (max-width: 640px) {
  .download-ready {
    /* Anchored above the fixed bottom tab bar (or the reader's own bottom nav on a
       chapter page, PR 28/52) instead of below the fixed top account header - the top of
       the page is where a visitor's actual content starts on mobile (e.g. the home
       page's search field), so a toast there would sit on top of it instead of beside
       it like it does on desktop. */
    top: auto;
    bottom: 76px;
    right: 12px;
    left: 12px;
    max-width: none;
  }
}

/* Cookie notice banner (PR 185) - a plain informational bar, not a blocking consent
   modal: both cookies this site sets (the session cookie and the anonymous
   recent_titles history) are strictly functional, so this only has to inform, not gate
   the rest of the page behind an opt-in click. Spans the full viewport width, so it
   sits on top of .sidebar's bottom edge on desktop rather than beside it - the sidebar
   itself is `position: sticky`, not fixed, so it doesn't fight this for the same
   corner. */
.cookie-notice {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 70;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 16px;
  flex-wrap: wrap;
  padding: 12px 20px;
  background: var(--bg-surface);
  border-top: 1px solid var(--border);
  box-shadow: 0 -8px 20px rgba(0, 0, 0, 0.25);
}

.cookie-notice[hidden] {
  display: none;
}

.cookie-notice__text {
  margin: 0;
  font-size: 13px;
  color: var(--text-secondary);
}

@media (max-width: 640px) {
  .cookie-notice {
    /* Same 76px clearance above the fixed bottom tab bar as .download-ready/
       .toc__export-panel (PR 51) use on mobile. */
    bottom: 76px;
  }
}

/* Fullscreen image viewer (PR 66) - image-lightbox.js only ever creates this overlay
   when tap-to-read (PR 62-65) is off, so it never has to coexist with that mode's own
   "tap the reading area to reveal the next paragraph" behavior. z-index above even the
   "file ready" toast above (60) - a fullscreen viewer should sit above every other
   overlay in the app, not behind one. */
.image-lightbox {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: none;
  align-items: center;
  justify-content: center;
  background: rgba(0, 0, 0, 0.9);
}

.image-lightbox--open {
  display: flex;
}

.image-lightbox__viewport {
  max-width: 90vw;
  max-height: 80vh;
  overflow: auto;
  display: flex;
  align-items: center;
  justify-content: center;
}

.image-lightbox__image {
  max-width: 100%;
  max-height: 80vh;
  border-radius: 6px;
  transform-origin: center center;
  transition: transform 0.15s ease;
}

.image-lightbox__close,
.image-lightbox__nav {
  position: absolute;
  background: rgba(26, 25, 34, 0.7);
  border: 1px solid var(--border);
  color: var(--text-primary);
  font-size: 22px;
  line-height: 1;
  width: 40px;
  height: 40px;
  border-radius: 999px;
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease;
}

.image-lightbox__close {
  top: 16px;
  right: 16px;
}

.image-lightbox__nav {
  top: 50%;
  transform: translateY(-50%);
}

.image-lightbox__nav--prev {
  left: 16px;
}

.image-lightbox__nav--next {
  right: 16px;
}

.image-lightbox__nav[hidden] {
  display: none;
}

.image-lightbox__toolbar {
  position: absolute;
  bottom: 16px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 14px;
  background: rgba(26, 25, 34, 0.7);
  border: 1px solid var(--border);
  border-radius: 999px;
}

.image-lightbox__zoom-in,
.image-lightbox__zoom-out,
.image-lightbox__download {
  background: none;
  border: none;
  color: var(--text-primary);
  font-size: 18px;
  line-height: 1;
  cursor: pointer;
  text-decoration: none;
}

.image-lightbox__counter {
  font-family: var(--font-mono);
  font-size: 12px;
  color: var(--text-secondary);
  min-width: 44px;
  text-align: center;
}

@media (hover: hover) {
  .image-lightbox__close:hover,
  .image-lightbox__nav:hover {
    border-color: var(--accent-teal);
    color: var(--accent-teal);
  }

  .image-lightbox__zoom-in:hover,
  .image-lightbox__zoom-out:hover,
  .image-lightbox__download:hover {
    color: var(--accent-teal);
  }
}

@media (max-width: 640px) {
  .image-lightbox__nav {
    width: 36px;
    height: 36px;
    font-size: 18px;
  }
}
