:root {
    color-scheme: light;
    --background: #ffffff;
    --surface: #fafafa;
    --surface-raised: #ffffff;
    --foreground: #09090b;
    --muted: #71717a;
    --subtle: #a1a1aa;
    --border: #e4e4e7;
    --input: #d4d4d8;
    --primary: #18181b;
    --primary-hover: #27272a;
    --primary-foreground: #fafafa;
    --danger: #b42318;
    --focus: #18181b;
    --shadow: rgba(9, 9, 11, .06);
    --accent: #e11d48;
    /* The filled call-to-action, which cannot just be --accent.
       That token flips between themes for text and marks, where each value is legible
       against its own background — but a FILLED button also has to carry a label. In
       dark mode --accent is #fb7185, a light pink, and white text on it measures 2.7:1,
       under the 4.5:1 floor. So the CTA gets its own pair: the surface stays rose in
       both themes, and the label flips to whichever of white/near-black actually reads
       against it. Measured: 4.7:1 here, 6.3:1 in dark. */
    --cta: #e11d48;
    --cta-hover: #be123c;
    --cta-foreground: #ffffff;
    --rank-first: #8a5b00;
    --rank-second: #52606d;
    --rank-third: #9a4f1d;
    /* One semantic accent beyond the brand rose: green for rank climbs. */
    --accent-green: #15803d;
    --content: 1240px;
    /* Responsive side gutter for all main containers (16–20px each side). */
    --gutter: clamp(2rem, 8vw, 2.5rem);
    /* Type scale — one size per role, shared across pages & devices. */
    --text-label: .6875rem;   /* 11px — uppercase micro-labels, chips, stat labels */
    --text-caption: .75rem;   /* 12px — author byline, helper text */
    --text-body: .8125rem;    /* 13px — inputs, selects, buttons, pagination */
    --text-title: .875rem;    /* 14px — row titles, stat values, section heading */
    --text-lead: 1rem;        /* 16px — lede paragraphs, card headings, the book title */
    --text-stat: 1.25rem;     /* 20px — the count numeral */
    --text-display: 1.75rem;  /* 28px — page title */
    /* 17px — the blog's reading size, and the only place on the site that has one.
       Every other surface is a listing: a row title, a stat, a caption beside a cover,
       all of which are scanned rather than read, and 13-14px is right for scanning. An
       article is two thousand words somebody reads in sequence, and the same 14px that
       makes a table dense makes an essay tiring. Named rather than written inline so
       the next long-form page takes this size instead of picking its own. */
    --text-prose: 1.0625rem;
    /* Weight scale — three values, and the rule for which. The file had eight (400,
       500, 550, 600, 640, 650, 700, 800), which is not a scale, it is what happens when
       every page picks a weight by eye. Bold is gone entirely: at Inter's proportions
       600 already reads as emphatic at display sizes, and 700/800 on a 14px row title
       was the site shouting at itself.

         normal  body copy, secondary text, anything the eye should slide over
         medium  THE DEFAULT EMPHASIS — row titles, buttons, active states, values,
                 field labels, card headings. If it is not display type, it is this.
         strong  display type only (page/section/detail/chart/state titles and the big
                 stat numerals), plus the 11px uppercase micro-labels, where medium is
                 genuinely too faint to read at that size and letter-spacing.

       Named rather than numeric so the question at each call site is "what is this
       text FOR", which has an answer, instead of "how bold should it be", which does
       not. */
    --weight-normal: 400;
    --weight-medium: 500;
    --weight-strong: 600;
    /* Radius scale. Fourteen distinct radii were in use — 2, 4, 5, 6, 7, 8, 10, 12, 14,
       28, 50% and 999px — so no two panels on the site had the same corner. Three plus
       the pill, related by the containment rule every design system uses: something
       nested inside a rounded box takes the next size down, so its corner sits parallel
       to its parent's rather than fighting it. 50% (avatars, dots) is a circle, not a
       radius, and stays as it is. */
    --radius-sm: 6px;
    --radius: 8px;
    --radius-lg: 12px;
    --radius-pill: 999px;
    /* The medal fills. They were six hex literals written out twice — once for the
       shelf rank badge and once for the book page's honour badge — and being literals
       they were the one thing on the site that did not change between themes. A filled
       medal carries its own ink, so one pair each serves both themes. */
    --medal-first: #f5c542;
    --medal-first-ink: #5a4300;
    --medal-second: #c7d0da;
    --medal-second-ink: #3a4453;
    --medal-third: #eaa066;
    --medal-third-ink: #6b3410;
    /* The footer's tint, and what body carries so Safari tints the bottom toolbar to
       match it. Derived rather than fixed: a custom property is substituted where it
       is used, so var(--foreground)/var(--background) inside it resolve against the
       active theme and dark mode needs no second declaration. */
    --plinth: color-mix(in srgb, var(--foreground) 3%, var(--background));
  }

  :root[data-theme="dark"] {
    color-scheme: dark;
    /* Dark palette copied from the app's `.dark` theme (HSL → hex/rgba):
       background 11%, card 14%, secondary/accent 19%, muted-fg 63%, ring 45%,
       primary 90%, destructive, and white-alpha border/input. */
    --background: #1c1c1c;
    --surface: #303030;
    --surface-raised: #242424;
    --foreground: #fafafa;
    --muted: #a0a0a0;
    --subtle: #737373;
    --border: rgba(255, 255, 255, .1);
    --input: rgba(255, 255, 255, .15);
    --primary: #e5e5e5;
    --primary-hover: #d4d4d4;
    --primary-foreground: #1c1c1c;
    --danger: #d54444;
    --focus: #737373;
    --shadow: rgba(0, 0, 0, .4);
    --accent: #fb7185;
    /* Lighter rose, dark label — the inverse of the light theme, for the reason above.
       Hover goes lighter here (#fda4af, 8.9:1) where light-theme hover goes darker
       (#be123c, 6.3:1): in both cases away from the background it sits on. */
    --cta: #fb7185;
    --cta-hover: #fda4af;
    --cta-foreground: #1c1c1c;
    --rank-first: #f6c453;
    --rank-second: #cbd5e1;
    --rank-third: #e9a66c;
    --accent-green: #4ade80;
  }

  *, *::before, *::after { box-sizing: border-box; }
  html { max-width: 100%; overflow-x: clip; scroll-behavior: smooth; }

  /* Custom scrollbar — slim, rounded, theme-aware, site-wide. Elements that hide their
     scrollbar on purpose (carousels, dropdowns) opt out with their own higher-specificity
     rules, so they're unaffected. Overlay scrollbars on touch devices ignore this. */
  * { scrollbar-width: thin; scrollbar-color: color-mix(in srgb, var(--muted) 40%, transparent) transparent; }
  ::-webkit-scrollbar { width: 11px; height: 11px; }
  ::-webkit-scrollbar-track { background: transparent; }
  ::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--muted) 42%, transparent); border-radius: var(--radius-pill); border: 3px solid transparent; background-clip: padding-box; }
  @media (hover: hover) { ::-webkit-scrollbar-thumb:hover { background: color-mix(in srgb, var(--muted) 68%, transparent); background-clip: padding-box; } }
  ::-webkit-scrollbar-corner { background: transparent; }
  body {
    margin: 0;
    max-width: 100%;
    min-height: 100vh;
    min-height: 100dvh;
    display: flex;
    flex-direction: column;
    overflow-x: clip;
    /* The plinth, not the page colour — and the page still looks unchanged, because
       the header, main and footer below paint over every pixel of it.

       Safari decides what to tint the bottom toolbar and the overscroll region with
       by sampling a fixed or sticky element near the viewport edge, and failing that
       the *body's* background-color. html is never consulted, which is why colouring
       html did nothing on two separate attempts, and why a box-shadow inside the
       document could not reach that strip either. body is the only surface it reads,
       so body is what has to carry the footer's tint.

       The cost is that rubber-banding past the *top* shows the plinth rather than the
       page colour. It is a 3% shift in light and a shade lighter in dark — far less
       noticeable than the seam under the footer that it buys. */
    background: var(--plinth);
    color: var(--foreground);
    font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    font-size: 16px;
    line-height: 1.5;
    text-rendering: optimizeLegibility;
  }
  /* .site-main is a centred column, so it cannot carry the page colour on its own —
     body would show down both margins, which is exactly what happened when it tried.
     .site-canvas is the full-bleed surface that paints the page colour; the column
     sits inside it, unchanged. The header and footer already span the full width and
     paint themselves, so between the three of them every pixel of the document is
     covered and body shows only where the browser paints outside it.

     Main grows to fill short pages so the header stays anchored at the top; the
     wrapper has to grow with it for that to still hold. */
  .site-canvas { flex: 1 0 auto; display: flex; flex-direction: column; background: var(--background); padding-bottom: 4rem; }
  .site-main { flex: 1 0 auto; }
  a { color: inherit; text-decoration: none; }
  button, input, select, textarea { color: inherit; font: inherit; }
  button { cursor: pointer; }
  /* No double-tap zoom on anything you are meant to tap. A phone waits ~300ms after a
     tap on an interactive element to see whether a second one is coming, and if it is,
     zooms instead of acting — so tapping the hamburger twice in a row zoomed the page.
     `manipulation` opts out of double-tap zoom only: panning and deliberate pinch-zoom
     still work, so nothing about reading the page changes.

     Deliberately not on the carousels — they set their own touch-action to keep
     horizontal panning, and this must not overwrite it. */
  a, button, summary, label, [role="button"], input, select, textarea { touch-action: manipulation; }
  img { display: block; max-width: 100%; }
  h1, h2, h3, p { margin-block-start: 0; }
  [lang="zh-Hans"] { font-family: "Noto Sans SC", "Microsoft YaHei", ui-sans-serif, sans-serif; }
  ::selection { background: color-mix(in srgb, var(--primary) 24%, transparent); }

  .sr-only {
    position: absolute !important;
    width: 1px !important;
    height: 1px !important;
    padding: 0 !important;
    margin: -1px !important;
    overflow: hidden !important;
    clip: rect(0, 0, 0, 0) !important;
    white-space: nowrap !important;
    border: 0 !important;
  }
  :where(a, button, input, select, summary):focus-visible {
    outline: 3px solid var(--focus);
    outline-offset: 2px;
  }

  .site-header {
    position: sticky;
    top: 0;
    z-index: 50;
    /* Solid bar (no backdrop-blur anywhere): re-compositing a blurred sticky region on
       every scroll frame hurts scroll smoothness on desktop and mobile alike. */
    background: var(--background);
  }

  /* Site footer — two balanced clusters: brand + links on the left, the sister apps
     on the right. Both are two rows tall, which is the whole trick: the apps used to
     stack vertically with their taglines, towering over a two-line links column and
     leaving the footer lopsided. */
  /* A tinted plinth rather than a hairline. A rule across the page reads as one more
     divider between sections — the same mark that separates a chart from the list
     below it — so the page looked like it had one more section rather than an end.
     A 3% wash of the foreground reads as the floor the page sits on: no line, and
     still unmistakably over. Mixing against the foreground keeps one rule for both
     themes (near-#f7f7f7 on white, a slight lift on the dark background). */
  /* The gap above the footer is the canvas's padding, not the footer's margin. A
     margin is transparent, so it showed body — which now carries the plinth, and the
     footer looked 4rem taller than it is. As padding it belongs to the page surface
     and stays the page colour, exactly as it looked before body was recoloured. */
  .site-footer { background: var(--plinth); }
  .footer-inner { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 1.75rem 3rem; width: min(var(--content), calc(100% - var(--gutter))); margin: 0 auto; padding: 2.25rem 0 2.6rem; }
  .footer-brand-col { display: flex; flex-direction: column; gap: .8rem; }
  /* Plain footer text, same as the links beside it — the header carries the wordmark. */
  .footer-brand { color: var(--muted); font-size: var(--text-body); font-weight: var(--weight-normal); }
  .footer-nav { display: flex; gap: 1.1rem; flex-wrap: wrap; }
  /* The policy row is a second line, not a lesser one: same colour and size as the links
     above it, so the footer reads as one set of links that happens to sit on two rows. */
  .footer-legal { display: flex; gap: 1.1rem; flex-wrap: wrap; }
  .footer-nav a, .footer-legal a { color: var(--muted); font-size: var(--text-body); }
  .footer-more { display: grid; grid-template-columns: repeat(2, auto); justify-content: start; align-items: start; gap: .8rem 2rem; }
  .footer-more-label { grid-column: 1 / -1; color: var(--subtle); font-size: var(--text-label); font-weight: var(--weight-strong); text-transform: uppercase; letter-spacing: .05em; }
  .footer-app { display: flex; align-items: center; gap: .6rem; }
  .footer-app-ico { flex: none; width: 24px; height: 24px; border-radius: var(--radius-sm); }
  .footer-app-txt { display: flex; flex-direction: column; gap: .04rem; min-width: 0; }
  /* App names read as ordinary footer links (same colour/size/weight as the nav), not headings. */
  .footer-app-txt strong { font-weight: var(--weight-normal); font-size: var(--text-body); color: var(--muted); }
  .footer-app-txt span { color: var(--muted); font-size: var(--text-caption); }
  @media (hover: hover) { .footer-nav a:hover, .footer-legal a:hover, .footer-app:hover strong { color: var(--foreground); } }

  .header-inner {
    display: grid;
    grid-template-columns: auto minmax(220px, 360px) minmax(0, 1fr) auto;
    align-items: center;
    gap: clamp(.85rem, 2vw, 1.75rem);
    width: min(var(--content), calc(100% - var(--gutter)));
    min-height: 64px;
    margin: 0 auto;
    position: relative;
  }
  .brand-wordmark {
    font-size: 1.06rem;
    font-weight: var(--weight-strong);
    letter-spacing: -.03em;
  }
  /* overflow is visible, not an x-scroller, because the nav's second level is a
     popover anchored inside it — a scroll container would clip every menu at the
     navbar's own height, at every breakpoint. It scrolled to absorb a long row of flat
     links; the row is now one chip per source, which fits a 320px phone with room to
     spare, and the sections it opens are a panel rather than more row. */
  .nav-scroller {
    display: flex;
    justify-content: flex-end;
    min-width: 0;
    overflow: visible;
    padding-inline: 4px;
  }
  .main-nav { display: flex; align-items: center; gap: .3rem; width: max-content; }
  .nav-link {
    border-radius: var(--radius-sm);
    padding: .55rem .7rem;
    color: var(--muted);
    font-size: var(--text-title);
    font-weight: var(--weight-medium);
  }
  @media (hover: hover) { .nav-link:hover { background: var(--surface); color: var(--foreground); } }
  /* Same weight as an inactive chip. The fill and the foreground colour already say
     which source you are in, and a third signal on top of them was the one bit of
     semibold in a navbar that is otherwise all medium. */
  .nav-link.is-active { background: var(--surface); color: var(--foreground); }

  /* The nav has two levels: the source, and the pages that source has. The second
     level is a native <details> (see base.html), so these rules only dress one —
     the disclosure marker goes, the summary is the same chip the flat links always
     were, and [open] is what shows the menu. Nothing here depends on script. */
  .nav-group { position: relative; }
  .nav-group > summary { display: flex; align-items: center; gap: .3rem; cursor: pointer; list-style: none; -webkit-user-select: none; user-select: none; }
  .nav-group > summary::-webkit-details-marker { display: none; }
  .nav-group > summary .ico-chevron { width: 12px; height: 12px; color: var(--subtle); transition: transform .16s ease; }
  .nav-group[open] > summary { background: var(--surface); color: var(--foreground); }
  .nav-group[open] > summary .ico-chevron { transform: rotate(180deg); }
  /* Sized for what it holds. This is a two- or three-item menu of one-word links, and
     it kept picking up clothes that belong to a panel of results: it started at 11rem
     with a 12px radius and a 40px shadow, then sat at 10px/18px — still its own corner
     radius, unlike every other surface on the site.
     It is now the site's own default box: var(--radius), the border, and a shadow small
     enough to say "this floats" without drawing a second object underneath it. */
  .nav-menu {
    position: absolute;
    top: calc(100% + .3rem);
    left: 0;
    z-index: 70;
    display: grid;
    min-width: 8.5rem;
    padding: .25rem;
    border: 1px solid var(--border);
    border-radius: var(--radius);
    background: var(--surface-raised);
    box-shadow: 0 4px 12px var(--shadow);
    /* Grows out of the chip it belongs to rather than appearing over it: the panel is
       anchored at the corner nearest its trigger, so the scale reads as the menu
       unfolding from the thing you clicked.
       Plain ease-out, no overshoot: a bounce is a flourish, and three words do not
       need one — it was the thing that made a 136px box read as an event. */
    transform-origin: top left;
    animation: navMenuIn .13s ease-out;
  }
  .nav-group:last-child .nav-menu { transform-origin: top right; }
  /* Bridge the gap the menu is offset by. The .3rem between the chip and the panel
     is dead space belonging to neither, so a pointer travelling from one to the
     other leaves the <details> and mouseleave closes the menu before it can be
     clicked. This pseudo-element makes that strip part of the menu's hit area
     without changing what is drawn. */
  .nav-menu::before {
    content: "";
    position: absolute;
    left: 0;
    right: 0;
    top: -.4rem;
    height: .4rem;
  }
  /* The last source opens leftward. The nav sits hard against the right of the
     header on desktop and against the hamburger on a phone, so a menu hanging to
     the right of the rightmost chip is the one that runs off the screen. */
  .nav-group:last-child .nav-menu { left: auto; right: 0; }
  /* .45rem, not .55rem, and no gap between rows. The panel's own .25rem padding plus
     this is .7rem — exactly .nav-link's padding-inline — so a menu item's text sits
     directly under the label of the chip that opened it instead of a hair to its
     right. The rows are flush for the same reason a shadcn menu's are: a sliver of
     background between two hover targets is a gap you can lose the pointer in. */
  .nav-menu-link { display: block; border-radius: var(--radius-sm); padding: .4rem .45rem; color: var(--muted); font-size: var(--text-title); font-weight: var(--weight-normal); white-space: nowrap; transition: background .12s ease, color .12s ease; }
  @media (hover: hover) { .nav-menu-link:hover { background: var(--surface); color: var(--foreground); } }
  /* The page you are on is named by weight and colour, not by a second grey fill. The
     fill is what hover means in this menu, so spending it on the current page left the
     two states drawn identically — and put a permanent block of grey inside a panel
     whose whole job is to be three words on white. The chip above already carries the
     fill for the source you are in. */
  .nav-menu-link.is-active { color: var(--foreground); font-weight: var(--weight-medium); }
  /* Off everywhere. The menu is a popover hanging below the chip that opened it at
     every breakpoint now, phone included, so the chip is still on screen and is itself
     the way back. It earns its place again only in a form where the menu covers its
     own trigger — which is what the phone's second level used to be. */
  .nav-back { display: none; }

  .global-search { display: flex; align-items: stretch; border: 1px solid var(--input); border-radius: var(--radius); background: var(--surface-raised); overflow: hidden; }
  .global-search input { min-width: 0; width: 100%; border: 0; background: transparent; padding: .52rem .7rem; outline: 0; font-size: var(--text-title); }
  .global-search input:focus-visible { outline: 3px solid var(--focus); outline-offset: -3px; }
  .global-search button { display: grid; place-items: center; border: 0; border-left: 1px solid var(--border); background: transparent; padding: 0 .7rem; color: var(--muted); }
  @media (hover: hover) { .global-search button:hover { background: var(--surface); color: var(--foreground); } }
  .global-search button .ico-search { width: 15px; height: 15px; }
  .mobile-search { display: none; }
  .mobile-search .search-field { flex: 1; }
  /* Sleek search pill (desktop + mobile): leading magnifier, input, optional × close.
     Minimal — a quiet border, no glow/shadow on focus. */
  .searchbox { display: flex; align-items: center; width: 100%; height: 40px; border: 1px solid var(--input); border-radius: var(--radius-pill); background: var(--surface-raised); }
  .desktop-search .searchbox { height: 36px; }
  /* Extra breathing room between the DaoSearch wordmark and the search bar (desktop). */
  .desktop-search { margin-left: clamp(.5rem, 2vw, 1.5rem); }
  .searchbox:focus-within { border-color: var(--muted); }
  .searchbox .search-lead { display: grid; flex: none; place-items: center; padding-left: .8rem; color: var(--subtle); }
  .searchbox .search-lead .ico-search { width: 15px; height: 15px; }
  .searchbox input { flex: 1; min-width: 0; border: 0; background: transparent; padding: 0 .5rem; outline: 0; font-size: var(--text-title); }
  .searchbox input::-webkit-search-cancel-button, .searchbox input::-webkit-search-decoration { -webkit-appearance: none; appearance: none; }
  .searchbox input:focus-visible { outline: 0; }
  /* The scope control, inside the search pill on its trailing edge.
     Two forms, because js-select replaces the native element with the site's own
     dropdown: .search-scope is what a reader gets without script, .cs is what they get
     with it. Both are styled to belong to the field rather than to sit beside it — a
     hairline divides them from the input instead of a border of their own, since a
     bordered control inside a bordered pill reads as two things. */
  .searchbox .search-scope { flex: none; align-self: stretch; margin: .3rem 0; padding: 0 .55rem 0 .6rem; border: 0; border-left: 1px solid var(--border); background: transparent; color: var(--muted); font: inherit; font-size: var(--text-caption); font-weight: var(--weight-medium); line-height: 1; cursor: pointer; appearance: none; -webkit-appearance: none; }
  .searchbox .search-scope:focus-visible { outline: 0; color: var(--foreground); }
  @media (hover: hover) { .searchbox .search-scope:hover { color: var(--foreground); } }

  /* The enhanced form. Height comes off the pill, not off .cs-btn's 38px form-field
     minimum, and the menu is right-aligned so it opens inside the field rather than
     hanging off it.

     The divider and the vertical inset both live on .cs, not on the button. Put on the
     button they need `height: 100%` to fill the pill, and height plus vertical margin is
     a contradiction — margin does not shrink a height, so the button rendered full-height
     AND pushed down, hanging ~5px below the pill and cutting through its rounded end at
     every breakpoint. Here `align-self: stretch` sizes .cs to the pill minus its own
     margin, and the button simply fills .cs. */
  .searchbox .cs { flex: none; align-self: stretch; display: flex; margin: .3rem 0; border-left: 1px solid var(--border); }
  .searchbox .cs-btn { min-height: 0; height: auto; gap: .3rem; margin: 0; padding: 0 .6rem 0 .65rem; border: 0; border-radius: 0; background: transparent; color: var(--muted); font-size: var(--text-caption); font-weight: var(--weight-medium); white-space: nowrap; }
  @media (hover: hover) { .searchbox .cs-btn:hover { color: var(--foreground); } }
  .searchbox .cs.is-open .cs-btn, .searchbox .cs-btn:focus-visible { box-shadow: none; color: var(--foreground); }
  .searchbox .cs-caret { width: 13px; height: 13px; }
  .searchbox .cs-menu { right: 0; left: auto; min-width: 8.5rem; }
  /* The mobile field already ends in a close button, so a divider here would fall
     between two controls and read as a group of its own. */
  .mobile-search .searchbox .search-scope, .mobile-search .searchbox .cs { border-left: 0; }
  .searchbox .search-clear { display: grid; flex: none; place-items: center; width: 36px; height: 100%; border: 0; background: transparent; color: var(--muted); }
  @media (hover: hover) { .searchbox .search-clear:hover { color: var(--foreground); } }
  .searchbox .search-clear .ico-x { width: 14px; height: 14px; }

  /* Search field wrapper + autocomplete dropdown (header search). */
  .search-field { position: relative; }
  .search-field .global-search { width: 100%; }
  .search-suggest { display: none; position: absolute; top: calc(100% + .4rem); left: 0; right: 0; z-index: 70; max-height: 400px; overflow-y: auto; padding: .3rem; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface-raised); box-shadow: 0 14px 40px var(--shadow); scrollbar-width: none; }
  .search-suggest::-webkit-scrollbar { display: none; }
  .search-suggest.is-open { display: block; animation: fadeInDown .14s ease; }
  @keyframes fadeInDown { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: none; } }
  /* Shorter travel than fadeInDown: a three-word menu sliding 6px reads as a
     panel arriving, which is more event than opening a nav menu deserves. Shorter
     again now — 2px and 98%, which is shadcn's own slide-in-from-top-2 / zoom-in-95
     rounded down. Below about this the motion stops being read as motion and starts
     being read as the menu simply being there, which is the point. */
  @keyframes navMenuIn { from { opacity: 0; transform: translateY(-2px) scale(.98); } to { opacity: 1; transform: none; } }
  .search-suggest-row { display: flex; align-items: center; gap: .6rem; padding: .45rem .5rem; border-radius: var(--radius); color: var(--foreground); }
  @media (hover: hover) { .search-suggest-row:hover { background: var(--surface); } }
  /* One definition for both dropdowns that show a cover: the header search and the
     booklists page's book picker. Same size (booklistSuggestCoverWidth in Go picks the
     CDN variant to match), so a novel looks the same wherever it is being searched for.
     The empty box is deliberate — it holds the row's alignment when a book has no cover. */
  .search-suggest-cover, .tag-suggest-cover { position: relative; flex: none; width: 30px; aspect-ratio: 2 / 3; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); }
  .search-suggest-cover img, .tag-suggest-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
  .search-suggest-text { display: grid; min-width: 0; }
  .search-suggest-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-title); font-weight: var(--weight-medium); }
  .search-suggest-author { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: var(--text-caption); }
  .search-suggest-empty { padding: .6rem; color: var(--subtle); font-size: var(--text-body); text-align: center; }
  .search-suggest-adv { display: block; margin-top: .2rem; padding: .55rem .6rem; border-top: 1px solid var(--border); color: var(--accent); font-size: var(--text-body); font-weight: var(--weight-medium); text-align: center; }
  @media (hover: hover) { .search-suggest-adv:hover { background: var(--surface); } }

  /* Icon buttons (search + menu + theme) — no outer box, just a subtle hover. */
  .search-toggle, .theme-toggle, .nav-toggle { display: grid; width: 40px; height: 40px; place-items: center; border: 0; border-radius: var(--radius); background: transparent; color: var(--muted); }
  @media (hover: hover) { .search-toggle:hover, .theme-toggle:hover, .nav-toggle:hover { background: var(--surface); color: var(--foreground); } }
  .search-toggle, .nav-toggle { display: none; }
  .search-toggle .ico-search, .nav-toggle .ico-menu { width: 18px; height: 18px; }
  .theme-toggle svg { width: 17px; height: 17px; }
  .theme-sun { display: none; }
  [data-theme="dark"] .theme-sun { display: block; }
  [data-theme="dark"] .theme-moon { display: none; }

  .site-main { width: min(var(--content), calc(100% - var(--gutter))); min-height: 72vh; margin: 0 auto; padding: 3rem 0 2.5rem; }
  .site-main:focus { outline: 0; }

  /* About page — built on the charts/browse header, then a sleek explainer grid. */
  /* About: plain prose sections flowing down the full content width — no cards, no
     dividers, no max-width cap; one uniform text size for the lede and every section. */
  /* The about page carries the only running prose on the site, so it takes a looser
     line — but the size is the same --text-title the ledes and row titles use, and it
     runs the full column like every other page. The default lede measure (62ch) is set
     for a line of description under a heading; on a page that is nothing but prose it
     stopped mid-screen and left the right half of the page empty. Headings and section
     rhythm come from .section-title and .detail-section, so this page has no type of
     its own left. */
  /* Full-strength text, matching .reading-copy — the book page's synopsis, which is the
     other place on the site that carries a paragraph somebody is meant to read rather
     than scan. Muted is the right colour for a caption or a line of description under a
     heading; on a page that is nothing but prose it greys out everything the page has
     to say. Same size, same line-height, same colour as a synopsis now. */
  .about-page .charts-lede { max-width: none; color: var(--foreground); line-height: 1.7; }
  .about-body p { margin: 0; color: var(--foreground); font-size: var(--text-title); line-height: 1.7; }
  /* Sections used to be one paragraph each, so margin:0 was enough. The policy pages run
     to two or three and would otherwise close up into a single block. */
  .about-body p + p { margin-top: .85rem; }
  /* "Last updated" under a policy page's lede: a fact about the page, not part of it. */
  .static-updated { margin: .55rem 0 0; color: var(--subtle); font-size: var(--text-caption); }
  .about-body .discord-btn, .about-body .contact-btn { margin-top: 1.15rem; }

  /* ---- Blog ----------------------------------------------------------------
     Every other page here is a listing: a table of rows that wants the full 1240px.
     A page of prose does not, and giving it one was the whole problem — the text
     wrapped at its own reading measure and left half the page empty beside it, so the
     writing looked like it had been dropped into the corner of something larger.

     So the blog sets its own column and centres it. --blog-col is the article measure
     plus the contents rail; the index uses the narrower --blog-read on its own. */
  .blog-index, .blog-post { --blog-read: 46rem; --blog-col: 64rem; }
  .blog-index { max-width: var(--blog-read); margin-inline: auto; }
  .blog-index .charts-lede, .blog-post .charts-lede { max-width: none; }

  .blog-cats { margin-top: 1.75rem; }

  /* The rest, as a plain list. The date and length used to sit in a fixed column down
     the left, which pushed every title and description inward and left that rail mostly
     blank; they are one line under the title now, and the text gets the full measure. */
  .blog-list { margin-top: .5rem; display: flex; flex-direction: column; }
  .blog-item { padding: 1.6rem 1rem; margin-inline: -1rem; border-top: 1px solid var(--border); border-radius: var(--radius-lg); transition: background .12s ease; }
  .blog-item:first-child { border-top: 0; }
  @media (hover: hover) { .blog-item:hover { background: var(--surface); } }
  .blog-item-meta, .blog-byline { display: flex; flex-wrap: wrap; align-items: center; gap: .45rem; margin: .5rem 0 0; color: var(--muted); font-size: var(--text-caption); }
  .blog-cat { color: var(--accent); font-weight: var(--weight-strong); font-size: var(--text-label); letter-spacing: .08em; text-transform: uppercase; }
  .blog-dot { color: var(--subtle); }
  .blog-item-title { margin: 0; font-size: 1.25rem; font-weight: var(--weight-medium); letter-spacing: -.015em; line-height: 1.3; }
  .blog-item-title a { color: var(--foreground); text-decoration: none; }
  .blog-item-desc { margin: .55rem 0 0; color: var(--muted); font-size: var(--text-title); line-height: 1.6; }
  /* The title itself does nothing on hover: no colour change and no underline. The row's
     background wash is the whole affordance, and it already covers the title, so a second
     signal on the same pointer position was two answers to one question. */

  /* The lead post. Nine rows of identical weight tell a reader nothing about where to
     start; this is the same information at the size of a thing worth reading first.
     Marked by the site's own accent rule, at full width because the block IS the section.

     Defined AFTER .blog-item, not before it. These selectors are the same specificity, so
     source order decides, and written above they lost their border to the grey hairline
     every row carries — the exact trap the mobile overrides in this file already document.

     No inline margin/padding and no radius, unlike the rows: those carry a 1rem bleed so
     their hover wash extends past the text, and inherited here it gave the accent rule
     rounded ends that read as a rendering fault. Flush with the text instead. */
  .blog-lead { margin: 2.25rem 0 0; padding: 1.85rem 0 .6rem; border-top: 2px solid var(--accent); border-radius: 0; }
  .blog-lead-title { margin: 0; font-size: clamp(1.5rem, 3.2vw, 1.875rem); font-weight: var(--weight-strong); letter-spacing: -.03em; line-height: 1.2; }
  .blog-lead-title a { color: var(--foreground); text-decoration: none; }
  .blog-lead-desc { margin: .65rem 0 0; color: var(--muted); font-size: var(--text-lead); line-height: 1.6; }

  /* The article. Header and body share one centred column so the title sits over the
     text rather than beside it, and the lede is full-strength like the About page's:
     muted is the colour of a caption, and on a page that is nothing but writing it greys
     out the first thing the page says. */
  .blog-post { max-width: var(--blog-col); margin-inline: auto; }
  /* Qualified as .blog-post .charts-head rather than the .blog-post-head class the markup
     also carries, because .charts-head is a single class defined LATER in this file and
     would win the tie — which it did, leaving the header a flex row whose text column
     shrink-wrapped to 544px while the title had 736 to use. Third time this file's source
     order has decided a rule; when a blog selector fights a site-wide one, out-specify it.
     The markup keeps .blog-post-head as the name of the thing. */
  .blog-post .charts-head { display: block; max-width: var(--blog-read); }
  .blog-post .charts-title { max-width: none; font-size: clamp(1.875rem, 4vw, 2.625rem); letter-spacing: -.035em; line-height: 1.12; }
  .blog-post .charts-lede { max-width: 40rem; color: var(--foreground); font-size: var(--text-lead); line-height: 1.6; }
  .blog-post .charts-eyebrow a { color: inherit; text-decoration: none; }
  .blog-byline { margin-top: 1.1rem; padding-top: 1.1rem; border-top: 1px solid var(--border); max-width: var(--blog-read); }

  /* Prose, with the contents rail in the margin beside it. The rail is second in the
     source so the article comes first to a screen reader and to a crawler; the grid puts
     it right. */
  .blog-layout { margin-top: 2.75rem; display: grid; grid-template-columns: minmax(0, var(--blog-read)) minmax(0, 1fr); gap: 3.5rem; align-items: start; }
  .blog-toc { order: 2; position: sticky; top: 5.5rem; }
  .blog-toc-head { display: block; color: var(--subtle); font-size: var(--text-label); font-weight: var(--weight-strong); letter-spacing: .08em; text-transform: uppercase; }
  .blog-toc-links { display: flex; flex-direction: column; gap: .6rem; margin-top: .85rem; padding-left: .9rem; border-left: 1px solid var(--border); }
  .blog-toc-links a { color: var(--muted); font-size: var(--text-body); line-height: 1.35; text-decoration: none; }
  @media (hover: hover) { .blog-toc-links a:hover { color: var(--foreground); } }

  .blog-prose { order: 1; min-width: 0; color: var(--foreground); font-size: var(--text-prose); line-height: 1.75; }
  .blog-prose > :first-child { margin-top: 0; }
  .blog-prose p, .blog-prose ul, .blog-prose ol { margin: 0 0 1.25rem; }
  /* The opening paragraph is set a step up: it is the one the reader decides on. */
  .blog-prose > p:first-child { font-size: 1.1875rem; line-height: 1.7; color: var(--foreground); }
  .blog-prose h2 { margin: 3rem 0 1rem; font-size: 1.5rem; font-weight: var(--weight-strong); letter-spacing: -.025em; line-height: 1.25; scroll-margin-top: 5.5rem; }
  .blog-prose h3 { margin: 2.1rem 0 .7rem; font-size: 1.1875rem; font-weight: var(--weight-medium); letter-spacing: -.015em; line-height: 1.3; scroll-margin-top: 5.5rem; }
  .blog-prose ul, .blog-prose ol { padding-left: 1.35rem; }
  .blog-prose li { margin-bottom: .55rem; }
  .blog-prose li::marker { color: var(--subtle); }
  .blog-prose li > ul, .blog-prose li > ol { margin: .55rem 0 0; }
  /* An in-article link keeps its underline, because that is the only thing marking it as
     one in a page of running text — but it never changes colour. Hover only firms the
     underline it already has, so nothing on the blog turns rose under the pointer. */
  .blog-prose a { color: var(--foreground); text-decoration: underline; text-decoration-color: var(--input); text-underline-offset: .2em; text-decoration-thickness: 1.5px; }
  @media (hover: hover) { .blog-prose a:hover { text-decoration-color: var(--foreground); } }
  .blog-prose strong { font-weight: var(--weight-medium); color: var(--foreground); }
  /* A pull-quote is the only accent bar in running text, so it is the one thing that
     stops the eye. Used for the sentence a section turns on. */
  .blog-prose blockquote { margin: 2rem 0; padding: .25rem 0 .25rem 1.35rem; border-left: 3px solid var(--accent); color: var(--foreground); font-size: 1.1875rem; line-height: 1.6; }
  .blog-prose blockquote p:last-child { margin-bottom: 0; }
  .blog-prose code { padding: .12em .35em; border-radius: var(--radius-sm); background: var(--surface); font-size: .9em; }
  .blog-prose hr { margin: 2.75rem 0; border: 0; border-top: 1px solid var(--border); }
  /* Tables scroll inside their own box rather than widening the column. A comparison
     table is the format half these posts want, and one that pushes the page sideways on
     a phone breaks every other page the reader visits next. */
  .blog-prose table { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0 0 1.75rem; font-size: var(--text-body); }
  .blog-prose th, .blog-prose td { border-bottom: 1px solid var(--border); padding: .7rem .9rem; text-align: left; vertical-align: top; }
  .blog-prose thead th { background: var(--surface); color: var(--muted); font-size: var(--text-label); font-weight: var(--weight-strong); letter-spacing: .04em; text-transform: uppercase; white-space: nowrap; }
  .blog-prose tbody tr:last-child > * { border-bottom: 0; }
  .blog-related { max-width: var(--blog-read); margin-top: 4rem; border-top: 1px solid var(--border); padding-top: 2rem; }
  .blog-related .blog-list { margin-top: .75rem; }

  .page-title, .display-title, .section-title { color: var(--foreground); font-weight: var(--weight-strong); letter-spacing: -.035em; overflow-wrap: anywhere; }
  .display-title { max-width: 760px; margin-bottom: .8rem; font-size: clamp(2.25rem, 5vw, 3.5rem); line-height: 1.05; }
  .page-title { margin-bottom: .65rem; font-size: clamp(2rem, 4vw, 2.5rem); line-height: 1.1; }
  /* One section marker for the whole site: a short rose bar before the title.
     It started life on the book page and was the most distinctive device in the
     design while also being the only one used in exactly one place — home and stats
     set a bare title instead, so a reader met three different ideas of "a section"
     across three pages.
     Page headers keep the eyebrow (.charts-eyebrow) rather than taking the bar:
     naming which part of the site you are in is a different job from marking a
     block within a page, and that distinction is worth two devices. */
  /* One size, everywhere. This clamped up to 1.5rem while the book and stats pages
     each overrode it to 1.125rem, so the same kind of heading was three different
     sizes depending on the page — and on a wide desktop the home rails were shouting
     at 24px above 14px rows. 1.125rem is the size the other two pages had already
     settled on, so this adopts it rather than inventing a fourth. */
  .section-title { margin-bottom: 0; font-size: 1.125rem; letter-spacing: -.02em; line-height: 1.25; display: flex; align-items: baseline; gap: .5rem; }
  .section-title::before { content: ""; flex: none; align-self: center; width: 3px; height: .95em; border-radius: var(--radius-pill); background: var(--accent); }
  .lede { max-width: 700px; color: var(--muted); font-size: var(--text-lead); line-height: 1.65; }
  .eyebrow { margin: 0 0 .45rem; color: var(--muted); font-size: var(--text-caption); font-weight: var(--weight-strong); letter-spacing: .04em; text-transform: uppercase; }
  .section { margin-top: 3.5rem; }
  .section-head, .section-header { display: flex; align-items: end; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
  .section-note { margin: .3rem 0 0; color: var(--muted); font-size: var(--text-title); }
  .link-arrow, .text-link { color: var(--primary); font-size: var(--text-title); font-weight: var(--weight-medium); }
  @media (hover: hover) { .link-arrow:hover, .text-link:hover { color: var(--primary-hover); } }
  .mono { font-family: ui-monospace, SFMono-Regular, Consolas, monospace; }

  .button { display: inline-flex; min-height: 40px; align-items: center; justify-content: center; gap: .4rem; border: 1px solid transparent; border-radius: var(--radius); padding: .55rem .9rem; font-size: var(--text-title); font-weight: var(--weight-medium); }
  .button-primary { background: var(--primary); color: var(--primary-foreground); }
  @media (hover: hover) { .button-primary:hover { background: var(--primary-hover); } }
  .button-secondary { border-color: var(--input); background: var(--surface-raised); color: var(--foreground); }
  @media (hover: hover) { .button-secondary:hover { background: var(--surface); } }
  .field { display: grid; gap: .4rem; }
  .field-label { color: var(--foreground); font-size: var(--text-body); font-weight: var(--weight-medium); }
  .input, .select { width: 100%; min-height: 40px; border: 1px solid var(--input); border-radius: var(--radius); background: var(--surface-raised); padding: .55rem .7rem; outline: 0; }
  .input:focus, .select:focus { border-color: var(--focus); box-shadow: 0 0 0 3px color-mix(in srgb, var(--focus) 18%, transparent); }

  .source-qidian { --source: var(--accent); }
  /* Every source takes the site accent. The hook exists so a source CAN be tinted
     apart, but nothing on the site is telling a reader which catalogue they are in by
     colour — the badge already says it in words. */
  .source-fanqie { --source: var(--accent); }
  .source-badge { display: inline-flex; width: max-content; align-items: center; gap: .35rem; border: 1px solid color-mix(in srgb, var(--source, var(--primary)) 45%, var(--border)); border-radius: var(--radius-pill); background: color-mix(in srgb, var(--source, var(--primary)) 10%, transparent); padding: .2rem .5rem; color: var(--foreground); font-size: var(--text-label); font-weight: var(--weight-medium); }
  .source-badge::before { width: 6px; height: 6px; border-radius: var(--radius-pill); background: var(--source, var(--primary)); content: ""; }

  .home-intro { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, .72fr); gap: 1rem 3rem; align-items: end; border-bottom: 1px solid var(--border); padding: 1.25rem 0 2.5rem; }
  .home-intro-copy { grid-row: span 2; }
  .home-intro .display-title { max-width: 720px; }
  .home-intro .lede { margin-bottom: 0; }
  .home-search { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: .5rem; }
  .source-filter-list { display: flex; flex-wrap: wrap; gap: .5rem; }
  .source-filter { display: inline-flex; min-height: 34px; align-items: center; gap: .45rem; border: 1px solid var(--border); border-radius: var(--radius-pill); padding: .35rem .65rem; background: var(--surface-raised); color: var(--muted); font-size: var(--text-body); font-weight: var(--weight-medium); }
  @media (hover: hover) { .source-filter:hover { border-color: var(--input); background: var(--surface); color: var(--foreground); } }
  .source-dot { width: 7px; height: 7px; border-radius: var(--radius-pill); background: var(--source); }

  /* ===== Landing page ===== */
  /* Hero — the tagline and CTA over a slow wall of the catalogue's own cover art.
     The covers are the one thing this site has that no other catalogue does, and the
     wall says "hundreds of thousands of these" without a single statistic. It is the
     page's own material: every cover in it was already loaded for a section further
     down (see heroRows), asked for at the CDN's smallest 90px variant.
     Motion is one composited translate3d per strip and no JS. It is expressed as an
     @keyframes animation *because* of that: the global prefers-reduced-motion block at
     the foot of this file caps every animation-duration and iteration-count, so the
     wall freezes to a static wall there for free — a transition- or JS-driven drift
     would slip past it. */
  /* The hero's vertical padding is what makes the wall visible: the two strips are
     pinned to the top and bottom of the hero box (space-between) and the copy sits
     between them, so the art reads above and below the type rather than only in the
     margins — which on a 390px screen is nowhere at all. */
  .home-hero { position: relative; isolation: isolate; padding: 5.6rem 0 5.2rem; text-align: center; }
  /* The width cap is load-bearing, not styling: a strip holds 12 covers (heroStripLen,
     which home.go guarantees) at 72px of track each = 864px, the track holds three
     copies and drifts by one, so it only stays full while the wall is no wider than
     two copies — 1728px. The 1680px cap keeps that true on every desktop; past it the
     wall stops short of the screen edge, which its own edge fade makes look
     deliberate. Shortening the strip breaks this before it looks wrong on a phone. */
  .hero-wall { position: absolute; z-index: -1; top: 0; bottom: 0; left: 50%; display: flex; width: min(100vw, 1680px); flex-direction: column; justify-content: space-between; overflow: hidden; opacity: .5; transform: translateX(-50%); -webkit-mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent); mask-image: linear-gradient(to right, transparent, #000 12%, #000 88%, transparent); }
  :root[data-theme="dark"] .hero-wall { opacity: .34; }
  .hero-strip { display: flex; overflow: hidden; }
  .hero-track { display: flex; flex: none; width: max-content; animation: hero-drift 90s linear infinite; }
  /* The second strip drifts the other way, slower, and starts offset so the two rows
     never line up into a grid. The offset is also what the reduced-motion freeze lands
     on, so the static wall is staggered too. */
  .hero-strip:nth-child(2) .hero-track { transform: translate3d(-7%, 0, 0); animation-duration: 120s; animation-direction: reverse; }
  /* Margin rather than a flex gap: the track holds three copies of the strip and
     shifts by exactly one of them (-33.3333%), which is only seamless when every item
     occupies the same width including its trailing space. A gap would leave the gap
     between copies unaccounted for and the loop would jump by that much every cycle. */
  .hero-cover { flex: none; width: 66px; height: 99px; margin-right: 6px; border-radius: var(--radius-sm); background: var(--surface); object-fit: cover; }
  @keyframes hero-drift { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(-33.3333%, 0, 0); } }
  /* The scrim is one opaque slab of --background, blurred so it reads as a halo rather
     than a card. Blurred rather than faded: the type sits on the page's own background
     colour whatever cover happens to be behind it, so contrast is exactly what it is on
     a plain hero — in both themes — instead of depending on the artwork. */
  .hero-copy { position: relative; max-width: 560px; margin: 0 auto; }
  .hero-copy::before { position: absolute; z-index: -1; border-radius: var(--radius-lg); background: var(--background); content: ""; filter: blur(26px); inset: -2.6rem -2.25rem; }
  .home-title { margin: 0 auto; max-width: 24ch; font-size: clamp(1.45rem, 2.6vw, 1.8rem); font-weight: var(--weight-strong); letter-spacing: -.02em; line-height: 1.22; text-wrap: balance; }
  .home-bridge { max-width: 42ch; margin: .8rem auto 0; color: var(--muted); font-size: var(--text-lead); line-height: 1.6; }
  /* Discord CTA — brand blurple, used in the hero and the About page. */
  /* The one outbound call-to-action on a book page: request a translation of this novel
     on RandomTranslator. Filled in the brand rose rather than the neutral pill the rest
     of the site's buttons use — it is the only thing on the page asking to be clicked
     rather than read, and an outlined button sat in the hero looking like another
     metadata chip. The rose is ours, not the partner's, so it still reads as part of
     the site. */
  /* A .button first, so it inherits the site's type and focus behaviour; .request-btn
     recolours and reshapes it. The rose overrides .button-primary's near-black because
     this is the single thing on the page asking to be clicked rather than read, and in
     the primary colour it read as just another neutral control in the hero.
     Pill, not the shared var(--radius): at the button's default corner it sat in the
     hero as a rectangular slab among the rounded chips and metric pills around it. The
     radius and the trimmed height are what keep it reading as a call to action rather
     than as a panel. */
  .request-btn { margin-top: 1.1rem; min-height: 34px; padding: .42rem 1rem; border-radius: var(--radius-pill); background: var(--cta); color: var(--cta-foreground); font-size: var(--text-body); box-shadow: 0 1px 2px var(--shadow); transition: background .14s ease, transform .1s ease; }
  .request-btn .ico-translate { width: 15px; height: 15px; color: currentColor; }
  @media (hover: hover) { .request-btn:hover { background: var(--cta-hover); } }
  .request-btn:active { transform: translateY(1px); }
  .discord-btn { display: inline-flex; align-items: center; gap: .5rem; padding: .58rem 1.15rem; border-radius: var(--radius-pill); background: #5865f2; color: #fff; font-size: var(--text-body); font-weight: var(--weight-medium); transition: background .14s ease, transform .1s ease; }
  @media (hover: hover) { .discord-btn:hover { background: #4752c4; } }
  .discord-btn:active { transform: translateY(1px); }
  /* The site's own button, for the email address. Same shape as the Discord one so the
     two read as a pair on the contact page, in the site's near-black rather than a brand
     blue that isn't ours. */
  .contact-btn { display: inline-flex; align-items: center; gap: .5rem; padding: .58rem 1.15rem; border-radius: var(--radius-pill); background: var(--primary); color: var(--primary-foreground); font-size: var(--text-body); font-weight: var(--weight-medium); transition: background .14s ease, transform .1s ease; }
  @media (hover: hover) { .contact-btn:hover { background: var(--primary-hover); } }
  .contact-btn:active { transform: translateY(1px); }
  .contact-btn .ico-mail { width: 19px; height: 19px; }
  .discord-btn .ico-discord { width: 20px; height: 20px; }
  .home-hero .discord-btn { margin-top: 1.35rem; }

  /* Stats — a plain, minimal row of numbers (no panel). */
  .home-stats { display: grid; grid-template-columns: repeat(6, 1fr); gap: 1rem; max-width: 720px; margin: 1.9rem auto 0; }
  .home-stats div { display: flex; flex-direction: column; align-items: center; gap: .1rem; text-align: center; }
  .home-stats dd { margin: 0; font-size: clamp(1.2rem, 2.3vw, 1.55rem); font-weight: var(--weight-strong); letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
  .home-stats dt { color: var(--muted); font-size: var(--text-caption); }

  .home-genres { display: flex; flex-wrap: wrap; justify-content: center; gap: .45rem; max-width: 880px; margin: 2rem auto 0; scrollbar-width: none; }
  .home-genres::-webkit-scrollbar { display: none; }
  .home-genre-chip { flex: none; display: inline-flex; align-items: center; border: 1px solid var(--border); border-radius: var(--radius-pill); padding: .32rem .8rem; background: var(--surface-raised); color: var(--muted); font-size: var(--text-body); font-weight: var(--weight-medium); white-space: nowrap; -webkit-user-drag: none; transition: color .12s ease, border-color .12s ease, background .12s ease; }
  @media (hover: hover) { .home-genre-chip:hover { color: var(--foreground); border-color: var(--input); background: var(--surface); } }

  .home-body { display: flex; flex-direction: column; gap: 2.6rem; margin-top: 2.9rem; }
  .home-body .section { margin-top: 0; }

  /* rank badge overlaid on a carousel cover */
  .shelf-rank { position: absolute; z-index: 2; top: .4rem; left: .4rem; display: grid; place-items: center; min-width: 1.5rem; height: 1.5rem; padding: 0 .35rem; border-radius: var(--radius-pill); background: var(--primary); color: var(--primary-foreground); font-size: var(--text-label); font-weight: var(--weight-strong); font-variant-numeric: tabular-nums; box-shadow: 0 1px 4px var(--shadow); }
  /* One medal, drawn wherever a rank pill is filled. The book page's honour badge had
     its own copy of these six literals; both now read the tokens, so gold is one gold. */
  .shelf-rank.rank-first, .honour-badge.rank-first .honour-rank { background: var(--medal-first); color: var(--medal-first-ink); }
  .shelf-rank.rank-second, .honour-badge.rank-second .honour-rank { background: var(--medal-second); color: var(--medal-second-ink); }
  .shelf-rank.rank-third, .honour-badge.rank-third .honour-rank { background: var(--medal-third); color: var(--medal-third-ink); }
  /* richer carousel card (recently updated): a compact "updated X ago" line */
  .shelf-card-rich { width: 138px; }
  .shelf-time { color: var(--subtle); font-size: var(--text-caption); }

  /* Most Collected — a compact numbered list instead of a second cover rail. Hairline
     rows, no card shell, the same list idiom as browse/rankings at a smaller scale.
     The 32px cover (48px tall) is what sets the row height: title and byline together
     come to ~36px, so the byline rides along at no cost in pixels. */
  /* Two chart lists side by side. The single list carried a 620px cap, which on a
     desktop content column read as a half-finished row; pairing it with a second
     board fills the width with something useful instead of stretching one list
     to a measure its rows do not want. */
  .chart-pair { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem 2.25rem; }
  .chart-pair .section { margin-top: 0; min-width: 0; }
  .mini-list { margin: 0; padding: 0; list-style: none; }
  .mini-list li + li { border-top: 1px solid var(--border); }
  .mini-row { display: grid; grid-template-columns: 1.15rem 32px minmax(0, 1fr) auto; align-items: center; gap: .7rem; padding: .3rem .3rem; border-radius: var(--radius); }
  @media (hover: hover) { .mini-row:hover { background: var(--surface); } }
  .mini-num { color: var(--subtle); font-size: var(--text-body); font-weight: var(--weight-strong); font-variant-numeric: tabular-nums; text-align: center; }
  .mini-num.rank-first { color: var(--rank-first); }
  .mini-num.rank-second { color: var(--rank-second); }
  .mini-num.rank-third { color: var(--rank-third); }
  .mini-cover { position: relative; width: 32px; height: 48px; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); }
  .mini-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
  .mini-cover .cover-fallback { font-size: .8rem; }
  .mini-body { display: flex; min-width: 0; flex-direction: column; gap: .05rem; }
  .mini-title { overflow: hidden; font-size: var(--text-title); font-weight: var(--weight-medium); text-overflow: ellipsis; white-space: nowrap; }
  .mini-author { overflow: hidden; color: var(--muted); font-size: var(--text-caption); text-overflow: ellipsis; white-space: nowrap; }
  .mini-metric { flex: none; color: var(--muted); font-size: var(--text-label); font-variant-numeric: tabular-nums; }
  /* 600, the weight every other figure on the site is set at (.chart-stat-val). At 650
     these two lists carried the heaviest numerals on the page while showing the
     smallest counts on it, so Fans and Recs read as the loudest thing on the home page. */
  .mini-metric strong { color: var(--foreground); font-size: var(--text-title); font-weight: var(--weight-medium); }

  /* Recently updated — a three-across cover grid rather than a rail: a glance at what
     moved, with every cover it holds on screen. The tile is the rail's own .shelf-card,
     re-sized; both overrides are (0,2,0) so they beat the base .shelf-card/.shelf-title
     rules further down this file and the (0,1,0) ones in the mobile block. */

  /* recent-comments — a vertical list of review rows (cover + book + comment) */
  .cm-list { display: flex; flex-direction: column; gap: .6rem; margin: 0; padding: 0; list-style: none; }
  .cm-list li[hidden] { display: none; }
  .cm-row { display: flex; gap: .8rem; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface-raised); padding: .85rem .95rem; transition: border-color .14s ease, background .14s ease; }
  @media (hover: hover) { .cm-row:hover { border-color: var(--input); background: var(--surface); } }
  .cm-cover { position: relative; flex: none; width: 40px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; border: 1px solid var(--border); background: var(--surface); }
  .cm-cover .cover-fallback { position: absolute; inset: 0; display: grid; place-items: center; color: var(--subtle); font-size: .9rem; }
  .cm-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
  .cm-content { min-width: 0; flex: 1; display: flex; flex-direction: column; gap: .3rem; }
  .cm-top { display: flex; align-items: center; gap: .5rem; }
  .cm-book-title { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-body); font-weight: var(--weight-medium); }
  .cm-time { flex: none; color: var(--subtle); font-size: var(--text-caption); }
  .cm-subject { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--text-body); font-weight: var(--weight-medium); font-style: italic; }
  .cm-body { color: var(--muted); font-size: var(--text-body); line-height: 1.55; white-space: pre-line; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; }

  .book-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .9rem; }
  .book-card { display: grid; min-width: 0; grid-template-columns: 104px minmax(0, 1fr); gap: .9rem; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-raised); padding: .8rem; transition: border-color .14s ease, box-shadow .14s ease, transform .14s ease; }
  @media (hover: hover) { .book-card:hover { border-color: color-mix(in srgb, var(--source, var(--primary)) 38%, var(--border)); box-shadow: 0 2px 10px var(--shadow); transform: translateY(-1px); } }
  .book-cover { position: relative; aspect-ratio: 2 / 3; align-self: start; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); }
  .book-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
  .cover-fallback { position: absolute; inset: 0; display: grid; place-items: center; padding: .3rem; background: color-mix(in srgb, var(--source, var(--muted)) 9%, var(--surface)); color: color-mix(in srgb, var(--source, var(--muted)) 60%, var(--subtle)); font-size: clamp(1.5rem, 4vw, 2.1rem); font-weight: var(--weight-medium); line-height: 1; text-align: center; }
  .book-card-body { display: flex; min-width: 0; flex-direction: column; align-items: flex-start; }
  .book-title { display: -webkit-box; margin: .55rem 0 0; overflow: hidden; color: var(--foreground); font-size: var(--text-lead); font-weight: var(--weight-medium); line-height: 1.35; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
  .book-original { display: -webkit-box; margin: .25rem 0 0; overflow: hidden; color: var(--muted); font-size: var(--text-caption); line-height: 1.35; -webkit-box-orient: vertical; -webkit-line-clamp: 1; }
  .book-author { margin: .4rem 0 0; color: var(--muted); font-size: var(--text-caption); line-height: 1.35; }
  .book-meta, .metric-list { display: flex; flex-wrap: wrap; gap: .25rem .6rem; margin: auto 0 0; padding-top: .65rem; color: var(--muted); font-size: var(--text-caption); }
  .book-meta span + span::before, .metric-list span + span::before { content: "·"; margin-right: .6rem; color: var(--subtle); }
  .metric-list { margin-top: 0; padding-top: .25rem; }

  .library-header { display: flex; align-items: end; justify-content: space-between; gap: 2rem; margin-bottom: 1.5rem; }
  .library-header .lede { margin-bottom: 0; }
  .library-count { display: grid; flex: none; margin: 0; text-align: right; }
  .library-count strong { font-size: 1.4rem; line-height: 1; font-weight: var(--weight-strong); font-variant-numeric: tabular-nums; }
  .library-count span { margin-top: .35rem; color: var(--muted); font-size: var(--text-caption); }
  .filter-bar { display: grid; grid-template-columns: minmax(220px, 1.7fr) repeat(4, minmax(130px, 1fr)); gap: .75rem; align-items: end; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); padding: 1rem; }
  .filter-actions { display: flex; flex-wrap: wrap; grid-column: 1 / -1; gap: .5rem; justify-content: flex-end; }
  /* No margin of its own: the list inside already carries .browse-list's 1.25rem, and
     the two stacked put browse's first row 3.25rem below its filter panel where the
     booklists and rankings lists sit at 1.25rem. The three index pages have the same
     furniture in the same order, so the gaps between it are the same too. */
  .library-results { margin-top: 0; }
  .results-head { display: flex; align-items: end; justify-content: space-between; gap: 1rem; margin-bottom: 1rem; }
  .result-count { flex: none; margin: 0; color: var(--muted); font-size: var(--text-body); }
  .pagination { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: .35rem; margin-top: 1.75rem; font-size: var(--text-body); }
  .pagination-link, .pagination-disabled { display: inline-grid; place-items: center; width: 36px; height: 36px; border: 1px solid var(--input); border-radius: var(--radius); }
  .pagination-link .ico-chevron, .pagination-disabled .ico-chevron { width: 16px; height: 16px; }
  .pagination-link { background: var(--surface-raised); color: var(--foreground); transition: background .12s ease, border-color .12s ease; }
  @media (hover: hover) { .pagination-link:hover { background: var(--surface); border-color: var(--muted); } }
  .pagination-disabled { color: var(--subtle); background: var(--surface); opacity: .7; cursor: default; }
  .pagination-pages { display: inline-flex; align-items: center; gap: .2rem; margin: 0 .25rem; }
  .pagination-page { display: inline-grid; place-items: center; min-width: 34px; height: 34px; padding: 0 .35rem; border: 1px solid transparent; border-radius: var(--radius); color: var(--muted); font-variant-numeric: tabular-nums; transition: background .12s ease, color .12s ease; }
  @media (hover: hover) { .pagination-page:hover { background: var(--surface); color: var(--foreground); } }
  .pagination-page.is-current { border-color: var(--input); background: var(--surface-raised); color: var(--foreground); font-weight: var(--weight-medium); }
  .pagination-gap { padding: 0 .15rem; color: var(--subtle); }

  /* In-place loading dim during instant navigation. */
  #results[aria-busy="true"] { opacity: .5; transition: opacity .12s ease; }

  /* ============================================================================
     Rankings page (top filters · top-3 podium · ranked list).
     Self-contained; shares only the design tokens.
     ============================================================================ */
  .charts-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 2rem; }
  .charts-eyebrow { margin: 0 0 .4rem; color: var(--accent); font-size: var(--text-label); font-weight: var(--weight-strong); letter-spacing: .09em; text-transform: uppercase; }
  .charts-title { margin: 0; font-size: var(--text-display); font-weight: var(--weight-strong); letter-spacing: -.03em; line-height: 1.1; color: var(--foreground); }
  .charts-title-sep { color: var(--subtle); font-weight: var(--weight-normal); }
  .charts-lede { margin: .5rem 0 0; max-width: 62ch; color: var(--muted); font-size: var(--text-title); line-height: 1.5; }
  .charts-stat { display: flex; flex: none; gap: 1.5rem; margin: 0; text-align: right; }
  .charts-stat div { display: grid; gap: .15rem; }
  .charts-stat dt { color: var(--subtle); font-size: var(--text-label); font-weight: var(--weight-strong); letter-spacing: .06em; text-transform: uppercase; }
  .charts-stat dd { margin: 0; font-size: var(--text-stat); font-weight: var(--weight-strong); line-height: 1.1; font-variant-numeric: tabular-nums; }

  /* Filter block: two aligned, labelled rows (Chart · Genre) split by a hairline,
     each with a swipeable control to the right of a fixed-width key. */
  .charts-filters { margin-top: 1.35rem; display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface-raised); padding: .35rem 1rem; }
  .filter-row { display: flex; align-items: center; gap: 1.1rem; min-width: 0; padding: .5rem 0; }
  .filter-key { flex: none; width: 46px; color: var(--subtle); font-size: var(--text-label); font-weight: var(--weight-strong); letter-spacing: .06em; text-transform: uppercase; }
  /* Rank type — a minimal underline tab strip; the active tab carries the rose mark. */
  /* Chart selector uses the same pill treatment as the genre chips (neutral pill,
     dark solid when selected) so both filter rows read as one consistent control. */
  .seg { display: flex; min-width: 0; flex-wrap: nowrap; gap: .4rem; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; cursor: grab; touch-action: pan-x pan-y; overscroll-behavior-x: contain; }
  .seg::-webkit-scrollbar { display: none; }
  .seg.is-dragging { cursor: grabbing; }
  .seg-item { flex: none; display: inline-flex; align-items: center; min-height: 32px; padding: .35rem .8rem; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface-raised); color: var(--muted); font-size: var(--text-body); font-weight: var(--weight-medium); white-space: nowrap; user-select: none; -webkit-user-drag: none; transition: color .12s ease, background .12s ease, border-color .12s ease; }
  @media (hover: hover) { .seg-item:hover { color: var(--foreground); border-color: var(--input); background: var(--surface); } }
  .seg-item.is-active { background: var(--primary); border-color: var(--primary); color: var(--primary-foreground); font-weight: var(--weight-medium); }
  @media (hover: hover) { .seg-item.is-active:hover { color: var(--primary-foreground); } }
  /* Genre — fully rounded, swipeable pills. */
  .chip-row { display: flex; min-width: 0; flex-wrap: nowrap; gap: .4rem; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; cursor: grab; touch-action: pan-x pan-y; overscroll-behavior-x: contain; }
  .chip-row::-webkit-scrollbar { display: none; }
  .chip-row.is-dragging { cursor: grabbing; }
  .chip { flex: none; display: inline-flex; align-items: center; min-height: 32px; padding: .35rem .8rem; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface-raised); color: var(--muted); font-size: var(--text-body); font-weight: var(--weight-medium); white-space: nowrap; user-select: none; -webkit-user-drag: none; transition: color .12s ease, background .12s ease, border-color .12s ease; }
  @media (hover: hover) { .chip:hover { color: var(--foreground); border-color: var(--input); background: var(--surface); } }
  .chip.is-active { background: var(--primary); border-color: var(--primary); color: var(--primary-foreground); font-weight: var(--weight-medium); }
  @media (hover: hover) { .chip.is-active:hover { color: var(--primary-foreground); } }
  /* The facet rails carried a fade on their trailing edge to signal "scrollable". It
     washed out the last chip into the panel behind it, which read as a rendering fault
     more often than as an affordance — a chip half-dissolved looks broken, not
     scrollable. The rails still scroll; a chip cut by the edge says so on its own. The
     home genre rail keeps its fade, where the chips sit on the page rather than inside
     a bordered panel and the gradient has something to fade into. */

  /* The rankings podium (top three as feature cards) and .chart-row (the ranks-4+ list
     that sat under it) used to live here. Rankings now renders the same .browse-row as
     browse and booklists, paginated fifty to a page, so both are gone — along with the
     nth-child rules that hid ranks 1-3 from the list on desktop and the podium on
     mobile. The row primitives below (.chart-cover, .chart-book, .chart-meta,
     .chart-byline, .chart-stat*) are shared by every listing and stay. */
  /* 56px. A book row carries one cover where a booklist row carries a deck of five, so
     at 40px the single piece of art in the row read as an afterthought; at 72px it
     started setting the row height itself, which the two-line synopsis should be doing.
     56px sits between: 84px of cover against roughly 90px of text block. */
  .chart-cover { position: relative; width: 56px; aspect-ratio: 2 / 3; border-radius: var(--radius-sm); overflow: hidden; border: 1px solid var(--border); background: var(--surface); }
  .chart-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
  .chart-cover .cover-fallback { display: grid; place-items: center; width: 100%; height: 100%; font-size: 1.1rem; color: var(--subtle); }
  .chart-book { display: flex; min-width: 0; flex-direction: column; gap: .18rem; }
  .chart-book strong { font-size: var(--text-title); font-weight: var(--weight-medium); line-height: 1.3; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  /* A ranked row for a book we hold no metadata for. Fanqie's rank pages are
     font-ciphered, so a board arrives as positions and ids and the titles are joined
     back from the catalogue; a book first seen on a board has none until it is scraped.
     Set in the muted colour at normal weight so the row reads as incomplete rather than
     as a book called nothing. */
  .row-untitled { color: var(--muted); font-weight: var(--weight-normal); font-style: italic; }
  .chart-meta { display: flex; align-items: center; gap: .4rem; min-width: 0; }
  .chart-byline { min-width: 0; flex-shrink: 1; overflow: hidden; text-overflow: ellipsis; color: var(--muted); font-size: var(--text-caption); white-space: nowrap; }

  /* Structured meta chips (author byline aside) — genre, word count, status. One
     neutral pill language so every fact reads the same; "done" adds a green dot. */
  .ico-meta { flex: none; width: 11px; height: 11px; }
  .meta-chip { display: inline-flex; flex: none; align-items: center; gap: .28rem; padding: .1rem .45rem; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--foreground); font-size: var(--text-label); font-weight: var(--weight-medium); white-space: nowrap; text-transform: capitalize; }
  .meta-chip .ico-meta { color: var(--subtle); }
  .status-dot { flex: none; width: 6px; height: 6px; border-radius: 50%; background: var(--subtle); }
  .chart-stats { display: flex; gap: 1.15rem; }
  .chart-stat { display: flex; flex-direction: column; align-items: flex-end; }
  .chart-stat-val { font-size: var(--text-title); font-weight: var(--weight-medium); font-variant-numeric: tabular-nums; }
  .chart-stat-lbl { color: var(--subtle); font-size: var(--text-label); font-weight: var(--weight-strong); letter-spacing: .04em; text-transform: uppercase; }
  .chart-stat[data-metric-state="missing"] .chart-stat-val { color: var(--subtle); font-weight: var(--weight-medium); }

  /* Movement capsule shared by podium + list (charts page). */
  .mv { display: inline-flex; align-items: center; gap: .08rem; padding: .1rem .32rem; border-radius: var(--radius-pill); font-size: .625rem; font-weight: var(--weight-medium); font-variant-numeric: tabular-nums; line-height: 1.4; }
  .mv .ico-caret { width: 7px; height: 7px; }
  .mv-up { color: var(--accent-green); background: color-mix(in srgb, var(--accent-green) 14%, transparent); }
  .mv-down { color: var(--danger); background: color-mix(in srgb, var(--danger) 14%, transparent); }
  .mv-new { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); font-size: .5625rem; letter-spacing: .05em; text-transform: uppercase; }

  @media (max-width: 640px) {
    .site-canvas { padding-bottom: 2.5rem; }
    /* Mobile keeps the same two clusters, stacked and left-aligned — but the site's
       own row comes first. It used to be second, which put an uppercase "MORE FROM
       US" advertising two other products at the top of DaoSearch's own footer, above
       its name and every link it owns. Order here now matches the DOM. */
    /* Centred, not spread. The wordmark-left / links-right row left a hole in the
       middle of the first line and the apps band ended well short of the right edge,
       so the whole block leaned left under a centred column of content. Centring the
       three lines gives the footer an axis; it costs about 30px of height, which is
       the one place on the page where height is cheap. */
    .footer-inner { gap: 1.15rem; padding: 1.5rem 0 1.8rem; flex-direction: column; align-items: center; text-align: center; }
    .footer-brand-col { flex-direction: column; align-items: center; gap: .45rem; }
    /* The wordmark goes on a phone: the header carries it a screen away, and repeating
       it centred above three links made a heading out of the site's own name. */
    .footer-brand { display: none; }
    .footer-nav { justify-content: center; }
    /* Flex, not the desktop two-column grid: on a 320px screen the two apps do not
       fit on one line, and a fixed pair of columns would overflow instead of wrap. */
    .footer-more { display: flex; flex-wrap: wrap; justify-content: center; }
    .footer-more-label { flex: 1 0 100%; }
    /* Footer links are the smallest targets on the site — 13px text in a row. Padding
       (not font size) takes each to ~36px tall, comfortably tappable, without making
       the footer louder or the type bigger. Negative margins keep the rows' visual
       spacing where it was, so the block grows by the target height alone. */
    .footer-nav { gap: 1.25rem; margin: -.6rem 0; }
    .footer-nav a { padding: .6rem 0; }
    /* Identical to the row above — same gap, same padded tap targets, centred with it. */
    .footer-legal { justify-content: center; gap: 1.25rem; margin: -.6rem 0; }
    .footer-legal a { padding: .6rem 0; }
    .footer-more { align-items: center; gap: .55rem 1.25rem; }
    .footer-app { padding: .5rem 0; margin: -.5rem 0; }
    /* Taglines are a desktop luxury: on mobile a second line would push the two apps
       out of the single row that keeps this band one line tall. */
    .footer-app-txt span { display: none; }
    .footer-app-txt strong { white-space: nowrap; }

    /* Finer eyebrow on a phone: one step below the 11px micro-label, lighter, wider
       tracked. At desktop weight it was the boldest thing in the footer — a heading
       for the smallest part of it. Here it just names the band and gets out of the
       way. Same 9px as the NEW pill, the site's smallest existing label. */
    .footer-more-label { grid-column: 1 / -1; margin-bottom: .1rem; font-size: .5625rem; font-weight: var(--weight-medium); letter-spacing: .09em; }

    .charts-head { flex-direction: column; align-items: flex-start; gap: 1rem; }
    .charts-stat { text-align: left; }
    .charts-title { font-size: 1.5rem; }
    /* A page-level state takes the header's mobile size with it. */
    .state-page .state-title { font-size: 1.5rem; }
    .state-title { font-size: 1.2rem; }
    .state-body { font-size: var(--text-body); }
    /* Browse header keeps the title + Titles stat side by side on mobile. */
    .charts-browse .charts-head, .booklists-page .charts-head { flex-direction: row; align-items: flex-end; gap: 1rem; }
    .charts-browse .charts-stat, .booklists-page .charts-stat { text-align: right; }
    /* Rankings header stats read as a compact inline row: "TITLES 100   UPDATED 1d ago". */
    .charts:not(.charts-browse):not(.booklists-page) .charts-stat { flex-wrap: wrap; gap: .35rem 1.25rem; align-items: baseline; }
    .charts:not(.charts-browse):not(.booklists-page) .charts-stat div { display: flex; align-items: baseline; gap: .4rem; }
    .charts:not(.charts-browse):not(.booklists-page) .charts-stat dd { font-size: var(--text-title); font-weight: var(--weight-strong); }

    /* Chart / Genre filters: drop the labels on mobile — the tabs vs chips read on
       their own — so the scrollers use the full width. */
    .charts-filters { padding: .35rem .8rem; }
    .filter-row { padding: .55rem 0; }
    .filter-key { display: none; }
    /* The mobile rankings block used to live here — hiding the podium, un-hiding ranks
       1-3, medal-colouring their badges, and restating the whole .chart-row grid. All of
       it is gone with .chart-row itself: rankings is a .browse-row list now and inherits
       the mobile treatment defined just below, badge included. */

    /* Book rows on mobile take the booklist row's shape exactly: cover top-left, the
       metric cluster beside it on the same line, and the text block spanning the full
       width underneath. The previous layout put the text in a narrow column beside the
       cover — fine for a title and three chips, but it left the synopsis about 270px to
       fill two lines with, which is roughly one clause. Full width nearly doubles that,
       and the metrics gain the empty space next to the cover that the old shape wasted.
       Same grid as ol.booklist-list .booklist-row below; only the covers slot differs,
       one piece of art rather than a deck. */
    /* This block precedes the base .browse-row rules in source order, so it must out-
       specify them: `ol.browse-list .browse-row` (0,2,1) beats the base `.browse-row`
       rule (0,1,0), and `.browse-row .browse-num` (0,2,0) beats `.browse-num`. */
    /* Three stacked bands: the cover beside the title it names, then the synopsis, then
       the metrics.

       This deliberately does NOT copy the booklist row's mobile grid ("covers stats" /
       "book book"). That shape works there because a booklist's cover slot is a deck of
       five overlapping covers — a block wide enough to balance the figures opposite it.
       A book has one cover, and at 46px the same grid left a dead strip across the
       middle of the first line with the art stranded away from its own title.
       Pairing the cover with the title closes that gap, and the synopsis and the
       metrics each get the full width instead of a column beside a thumbnail. */
    ol.browse-list .browse-row { grid-template-columns: auto minmax(0, 1fr); grid-template-areas: "cover book" "desc desc" "stats stats"; gap: .55rem .8rem; align-items: start; padding: 1rem .5rem; }
    /* Both markers are defined once in the base rules; what changes here is the
       geometry they hang off — 1rem of row padding and a 48px cover (72px tall). */
    .browse-row .browse-num { top: calc(1rem - 7px); }
    .browse-row .browse-rank .mv { top: calc(1rem + 61px); }
    /* 48px (72px tall) sits close to the height of the title block beside it, so the
       two halves of the first band end together instead of one overhanging the other. */
    .browse-row .chart-cover { width: 48px; }
    .browse-row .chart-book { min-width: 0; gap: .25rem; }
    .browse-row .chart-meta { flex-wrap: wrap; column-gap: .35rem; row-gap: .3rem; }
    .browse-row .chart-byline { flex: 1 1 100%; }
    .browse-row .meta-chip { padding: .08rem .4rem; }
    /* Full-width band, three groups spread edge to edge. The fixed 68px column that
       lines the figures up on desktop only crowds them at this width. */
    .browse-row .chart-stats { justify-content: space-between; gap: .5rem; margin-top: .15rem; padding-top: 0; }
    .browse-row .chart-stat { min-width: 0; align-items: flex-end; }
    .browse-row .chart-stat:first-child { align-items: flex-start; }
    .browse-row .chart-stat-lbl { letter-spacing: .04em; }

    /* All three metrics stay on mobile, and all three read the same. They were
       briefly reduced to the one the list was sorted by, then greyed back — but
       votes, recs and fans are the comparison a reader is actually making while
       scanning, and either treatment makes two of them harder to read for about
       16px of row height. Go still marks the sorted metric (markPrimaryMetric in
       rankings.go) for the desktop rows; on a phone it changes nothing. */

    /* Blog on a phone. The two-column index row collapses to one, with the date moved
       under the title where it reads as a caption rather than a stranded column. */
    .blog-item { padding: 1.35rem 0; margin-inline: 0; }
    .blog-item-title { font-size: 1.0625rem; }
    .blog-lead { margin-top: 2rem; }
    .blog-lead-desc { font-size: var(--text-title); }
    .blog-post .charts-title { max-width: none; }
    /* The contents rail stops being a rail and becomes the site's own horizontal
       scroller: eight stacked links above the first paragraph is a wall between the
       reader and the article they clicked. */
    .blog-layout { grid-template-columns: minmax(0, 1fr); gap: 1.75rem; margin-top: 2rem; }
    .blog-toc { order: 0; position: static; }
    .blog-toc-links { flex-direction: row; gap: .4rem; margin-top: .6rem; padding-left: 0; border-left: 0; overflow-x: auto; scrollbar-width: none; }
    .blog-toc-links::-webkit-scrollbar { display: none; }
    .blog-toc-links a { flex: none; padding: .35rem .8rem; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface-raised); white-space: nowrap; }
    .blog-prose { font-size: var(--text-lead); }
    .blog-prose > p:first-child { font-size: var(--text-lead); }
    .blog-prose h2 { margin-top: 2.4rem; font-size: 1.3125rem; }
    .blog-prose h3 { font-size: 1.125rem; }
    .blog-prose blockquote { font-size: var(--text-lead); }
    .blog-related { margin-top: 2.75rem; }

  }

  /* ================= Browse (/{source}/library) (quick bar + collapsible advanced panel) ================= */
  .browse-filters { margin-top: 1.35rem; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface-raised); padding: 1.1rem 1.2rem; box-shadow: 0 1px 2px var(--shadow); }

  /* Filters heading — title on the left, plain-text expand/collapse on the right. */
  .filter-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
  .filter-head-title { display: inline-flex; align-items: center; gap: .45rem; color: var(--foreground); font-size: var(--text-title); font-weight: var(--weight-medium); }
  .filter-head-title .ico-sliders { width: 16px; height: 16px; color: var(--muted); }
  .filter-toggle { display: inline-flex; align-items: center; gap: .3rem; padding: 0; border: 0; background: transparent; color: var(--muted); font-size: var(--text-body); font-weight: var(--weight-medium); white-space: nowrap; cursor: pointer; transition: color .12s ease; }
  @media (hover: hover) { .filter-toggle:hover { color: var(--foreground); } }
  .filter-toggle .ico-chevron { width: 15px; height: 15px; transition: transform .15s ease; }

  /* Quick bar — title search, sort, order. Always visible. */
  .filter-top { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem; }
  .filter-search { position: relative; flex: 1 1 240px; min-width: 170px; }
  .filter-search .ico-search { position: absolute; left: .65rem; top: 50%; transform: translateY(-50%); width: 15px; height: 15px; color: var(--subtle); pointer-events: none; }
  .filter-search .input { width: 100%; min-height: 38px; padding-left: 2rem; font-size: var(--text-body); }
  .filter-sort { flex: 0 1 165px; }
  .filter-order { flex: 0 1 145px; }
  .browse-filters.is-expanded .filter-toggle .ico-chevron { transform: scaleY(-1); }
  .filter-toggle .lbl-less { display: none; }
  .browse-filters.is-expanded .filter-toggle .lbl-more { display: none; }
  .browse-filters.is-expanded .filter-toggle .lbl-less { display: inline; }

  /* Collapsed summary — applied advanced filters as removable pills. */
  .filter-active { display: flex; flex-wrap: wrap; align-items: center; gap: .4rem; margin-top: .85rem; }
  .browse-filters.is-expanded .filter-active { display: none; }
  .active-chip { display: inline-flex; align-items: center; gap: .25rem; height: 27px; padding: 0 .35rem 0 .6rem; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface); color: var(--foreground); font-size: var(--text-caption); font-weight: var(--weight-medium); }
  @media (hover: hover) { .active-chip:hover { border-color: var(--muted); } }
  .active-chip .ax { display: inline-grid; place-items: center; width: 16px; height: 16px; border-radius: 50%; color: var(--muted); }
  @media (hover: hover) { .active-chip:hover .ax { color: var(--foreground); } }
  .active-chip .ax .ico-x { width: 10px; height: 10px; }

  /* Advanced panel — hidden until the toggle opens it. */
  .filter-advanced { display: none; margin-top: 1.1rem; }
  .browse-filters.is-expanded .filter-advanced { display: block; }
  .filter-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1rem 1.1rem; }
  .field-wide { grid-column: 1 / -1; }
  /* Sleeker, quieter field labels inside the filter panel. */
  .browse-filters .field { gap: .3rem; }
  .browse-filters .field-label { color: var(--muted); font-size: var(--text-label); font-weight: var(--weight-medium); letter-spacing: .01em; }
  .browse-filters .input, .browse-filters .select { min-height: 38px; font-size: var(--text-body); }
  /* And the buttons match them. .button is 40px at --text-title, which next to a 38px
     control at --text-body reads as a size mismatch rather than emphasis. */
  .browse-filters .button, .browse-filters .cs-btn { min-height: 38px; font-size: var(--text-body); }
  /* Tag filter sits below the grid (no divider above it). */
  .tag-filter { margin-top: 1.1rem; }
  .tag-filter > .field-label { display: block; color: var(--muted); font-size: var(--text-label); font-weight: var(--weight-medium); }
  .browse-actions { display: flex; align-items: center; justify-content: flex-end; gap: .5rem; margin-top: 1.1rem; }
  .browse-actions .button { min-height: 38px; padding: .4rem 1rem; font-size: var(--text-body); font-weight: var(--weight-medium); }

  /* Hide scrollbars in dropdown menus (scroll still works). */
  .cs-list, .tag-suggest { scrollbar-width: none; -ms-overflow-style: none; }
  .cs-list::-webkit-scrollbar, .tag-suggest::-webkit-scrollbar { width: 0; height: 0; display: none; }

  /* Native <select> fallback (shown only if JS is off): custom chevron. */
  .select { appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%2371717a' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M6 9l6 6 6-6'/></svg>"); background-repeat: no-repeat; background-position: right .6rem center; padding-right: 1.9rem; }

  /* Custom select: theme-consistent dropdown that enhances a hidden native <select>. */
  .cs { position: relative; }
  .cs-btn { display: flex; align-items: center; justify-content: space-between; gap: .5rem; width: 100%; min-height: 38px; padding: .4rem .7rem; border: 1px solid var(--input); border-radius: var(--radius); background: var(--surface-raised); color: var(--foreground); font-size: var(--text-body); line-height: 1.2; text-align: left; cursor: pointer; transition: border-color .12s ease, box-shadow .12s ease; }
  @media (hover: hover) { .cs-btn:hover { border-color: var(--muted); } }
  .cs.is-open .cs-btn, .cs-btn:focus-visible { border-color: var(--focus); box-shadow: 0 0 0 3px color-mix(in srgb, var(--focus) 18%, transparent); outline: 0; }
  .cs.is-disabled .cs-btn { opacity: .55; cursor: not-allowed; }
  .cs-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .cs-label.is-placeholder { color: var(--subtle); }
  .cs-caret-wrap { flex: none; display: grid; color: var(--subtle); }
  .cs-caret { width: 16px; height: 16px; transition: transform .15s ease; }
  .cs.is-open .cs-caret { transform: scaleY(-1); }
  .cs-menu { position: absolute; z-index: 40; left: 0; right: 0; top: calc(100% + .3rem); border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface-raised); box-shadow: 0 10px 30px var(--shadow); overflow: hidden; }
  .cs-search { padding: .35rem; border-bottom: 1px solid var(--border); }
  .cs-search-input { min-height: 32px; font-size: var(--text-body); }
  .cs-list { max-height: 250px; overflow-y: auto; padding: .25rem; }
  .cs-opt { display: flex; align-items: center; justify-content: space-between; gap: .5rem; padding: .4rem .55rem; border-radius: var(--radius-sm); font-size: var(--text-body); cursor: pointer; }
  @media (hover: hover) { .cs-opt:hover { background: var(--surface); } }
  .cs-opt.is-selected { font-weight: var(--weight-medium); }
  .cs-opt-check { flex: none; display: grid; color: var(--primary); }
  .cs-opt-check .cs-check { width: 15px; height: 15px; }
  .cs-empty { padding: .5rem .6rem; color: var(--subtle); font-size: var(--text-caption); text-align: center; }

  /* Browse rows — the rankings list minus the rank column: the cover leads. */
  .browse-list { margin: 1.25rem 0 0; padding: 0; list-style: none; }
  .browse-list > li { border-bottom: 1px solid var(--border); }
  .browse-list > li:last-child { border-bottom: 0; }
  /* The same desktop row as a booklist: 1.25rem gutters, top-aligned columns, and the
     roomier padding. It used to be a tighter, vertically-centred row with a 40px cover,
     which was fine while the row was one line of title and one of chips — with a
     two-line synopsis under them, centring floated the cover against a block of text
     and the cramped gutters ran the synopsis into the metric column. */
  .browse-row { position: relative; display: grid; grid-template-columns: 56px minmax(0, 1fr) auto; grid-template-areas: "cover book stats" "cover desc stats"; gap: .35rem 1.25rem; align-items: start; padding: 1.05rem .5rem; margin: 0 -.5rem; border-radius: var(--radius); transition: background .12s ease; }
  .browse-row .chart-cover { grid-area: cover; }
  .browse-row .chart-book { grid-area: book; }
  .browse-row .row-desc { grid-area: desc; }
  .browse-row .chart-stats { grid-area: stats; }
  @media (hover: hover) { .browse-row:hover { background: var(--surface); } }
  /* Metrics right-aligned in a fixed-width column, as on the booklists listing: the
     three figures line up down the page instead of drifting with the title beside
     them, which is what makes a column of numbers comparable at a glance. */
  .browse-row .chart-stats { align-self: start; gap: 1.75rem; padding-top: .15rem; }
  .browse-row .chart-stat { min-width: 68px; align-items: flex-end; }
  .browse-row .chart-stat-val { white-space: nowrap; }
  /* The title may wrap to two lines here. It is a single ellipsised line in the
     carousels, which is where the base rule's nowrap belongs. */
  .browse-row .chart-book { gap: .4rem; }
  .browse-row .chart-book strong { white-space: normal; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; line-height: 1.35; }
  .browse-row .chart-meta { flex-wrap: wrap; gap: .3rem .4rem; }
  /* The rank cluster is pinned to the cover's top-left corner, at both widths. The
     numeral used to hold a 44px column of its own on desktop and only become a badge on
     mobile, so the two breakpoints disagreed about where a rank lives and the column
     spent real width on two or three digits. Over the art it reads as a label on the
     book and costs nothing.
     One anchor, two stacked children: because the cluster is positioned rather than in
     flow, its place is identical on every row — a row with no movement simply has one
     child instead of two and nothing else shifts. */
  /* display:contents, so the two children position against the row (which is
     position:relative) rather than against each other. They are grouped in the markup
     because they are one fact — a rank and how it moved — but they sit at opposite
     corners of the cover, so neither can be in the other's flow. */
  .browse-rank { display: contents; }
  /* Rank at the cover's top-left, movement at its bottom-left, each overhanging the
     corner by the same 7px. Both are positioned, so both land in exactly the same place
     on every row whatever the row contains — which is the point: in the chip row the
     capsule's offset moved with the number of chips a book happened to carry.
     The vertical figure is the cover geometry, which is fixed: row padding (1.05rem),
     plus the cover's height (56px wide at 2:3 = 84px), minus the capsule's own height,
     plus the 7px overhang. */
  .browse-num { position: absolute; top: calc(1.05rem - 7px); left: calc(.5rem - 7px); z-index: 2; display: grid; place-items: center; min-width: 21px; height: 21px; padding: 0 5px; border-radius: var(--radius-pill); background: var(--primary); color: var(--primary-foreground); font-size: .66rem; font-weight: var(--weight-strong); font-variant-numeric: tabular-nums; line-height: 1; box-shadow: 0 1px 3px var(--shadow); }
  /* Fixed height so that arithmetic holds regardless of the digits inside. Opaque,
     unlike the capsule in a chip row: it sits over cover art, where the translucent
     tint it carries elsewhere is unreadable against a photograph. */
  .browse-rank .mv { position: absolute; top: calc(1.05rem + 73px); left: calc(.5rem - 7px); z-index: 2; height: 18px; margin: 0; padding: 0 .32rem; background: color-mix(in srgb, currentColor 16%, var(--surface-raised)); box-shadow: 0 1px 3px var(--shadow); }
  /* The synopsis (or, on a booklist card row, the list description): two clamped lines
     under the meta chips. The query hands over a 500-character prefix, so the clamp is
     hiding tens of characters rather than thousands. */
  .row-desc { display: -webkit-box; overflow: hidden; color: var(--muted); font-size: var(--text-body); line-height: 1.55; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }

  /* Tag filter: two searchable dropdowns (include + exclude), each with chips below. */
  .tag-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem 1.25rem; margin-top: .3rem; }
  .tag-col { display: grid; gap: .4rem; align-content: start; }
  .tag-col-head { display: flex; align-items: center; justify-content: space-between; min-height: 30px; }
  .tag-col-label { color: var(--muted); font-size: var(--text-label); font-weight: var(--weight-strong); letter-spacing: .05em; text-transform: uppercase; }
  .tag-col-label-ex { color: color-mix(in srgb, var(--danger) 70%, var(--muted)); }
  /* Tag match-mode: a compact custom select (Match all / Match any). */
  .tag-mode-select { width: 132px; }
  .tag-mode-select .cs-btn { min-height: 30px; padding: .2rem .55rem; font-size: var(--text-caption); }

  .tag-combobox { position: relative; }
  /* Same menu as the custom selects (.cs-menu/.cs-opt): same radius, padding, row
     height and max-height. These used to stack the works count under the tag name,
     making every row two lines tall — so the include/exclude dropdowns read as a
     size larger than every other dropdown in the panel. The count now sits at the
     end of the row, which is where .cs-opt puts its checkmark. */
  .tag-suggest { position: absolute; z-index: 30; left: 0; right: 0; margin-top: .3rem; max-height: 250px; overflow-y: auto; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface-raised); box-shadow: 0 10px 30px var(--shadow); padding: .25rem; }
  .tag-suggest-row { display: flex; align-items: center; justify-content: space-between; gap: .5rem; padding: .4rem .55rem; border-radius: var(--radius-sm); font-size: var(--text-body); }
  .tag-suggest-row.is-add { cursor: pointer; }
  @media (hover: hover) { .tag-suggest-row.is-add:hover { background: var(--surface); } }
  .tag-suggest-row.is-picked { opacity: .55; }
  .tag-suggest-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  /* Book-picker rows carry a cover and a stacked title/author, so the text block becomes
     the flex child that grows and the name inside it goes back to filling its own line.
     Taller rows than a tag row, hence the taller dropdown — at 250px only four of the
     eight results were reachable without scrolling. */
  .tag-suggest-text { display: grid; flex: 1; min-width: 0; }
  .tag-suggest-text .tag-suggest-name { flex: initial; }
  .tag-suggest-author { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: var(--text-caption); }
  .tag-suggest.tag-suggest-books { max-height: 340px; }
  .tag-suggest-sub { flex: none; color: var(--subtle); font-size: var(--text-caption); font-variant-numeric: tabular-nums; }
  .tag-suggest-picked { flex: none; color: var(--subtle); font-size: var(--text-label); font-weight: var(--weight-medium); }
  .tag-suggest-empty { padding: .5rem .6rem; color: var(--subtle); font-size: var(--text-caption); text-align: center; }

  .tag-chips { display: flex; flex-wrap: wrap; gap: .35rem; min-height: 1.5rem; align-content: start; }
  .tag-chips:empty::before { content: "None selected"; color: var(--subtle); font-size: var(--text-caption); }
  /* Include/exclude tag tokens: a coloured +/- sign badge (green = include, red =
     exclude) carries the meaning; the label stays neutral/readable and the pill is
     lightly tinted to match. Clearer and calmer than colouring the whole chip. */
  /* Same box as .active-chip — both are filter tokens with a remove button, and they
     appear a few rows apart, so they share a height rather than being 25px and 27px. */
  .tag-chip { display: inline-flex; align-items: center; gap: .25rem; height: 27px; padding: 0 .35rem 0 .6rem; border: 1px solid var(--border); border-radius: var(--radius-pill); background: var(--surface-raised); color: var(--foreground); font-size: var(--text-caption); font-weight: var(--weight-medium); }
  /* Include/exclude are distinguished by the green/red tint alone (no +/- glyph). */
  .tag-chip.is-in { border-color: color-mix(in srgb, var(--accent-green) 45%, var(--border)); background: color-mix(in srgb, var(--accent-green) 10%, var(--surface-raised)); }
  .tag-chip.is-ex { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); background: color-mix(in srgb, var(--danger) 9%, var(--surface-raised)); }
  .tag-chip-txt { line-height: 1.3; cursor: pointer; padding-left: .05rem; }
  .tag-chip-x { display: inline-grid; place-items: center; width: 16px; height: 16px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: currentColor; opacity: .55; cursor: pointer; transition: opacity .12s ease, background .12s ease; }
  @media (hover: hover) { .tag-chip-x:hover { opacity: 1; background: color-mix(in srgb, currentColor 18%, transparent); } }
  .tag-chip-x .ico-x { width: 10px; height: 10px; }
  @media (max-width: 860px) { .filter-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
  @media (max-width: 560px) {
    /* Two-up on mobile: Author full-width, then Genre/Subgenre, Status/Audience,
       Min/Max each pair on their own row. */
    .filter-grid { grid-template-columns: 1fr 1fr; }
    .filter-grid > .field:first-child { grid-column: 1 / -1; }
    .tag-cols { grid-template-columns: 1fr; }
    .filter-search { flex: 1 1 100%; }
    .filter-sort, .filter-order { flex: 1 1 auto; }
    /* Apply / Reset split the row half-and-half on mobile only. */
    .browse-actions .button { flex: 1 1 0; }
  }


  /* Fill the same content width as every other page (the responsive side gutter comes
     from .site-main); no extra inset on the book page. */
  .book-page { width: 100%; }
  .book-detail-header { display: grid; grid-template-columns: 164px minmax(0, 1fr); gap: clamp(1.25rem, 3.5vw, 2.5rem); align-items: start; }
  .detail-cover { position: relative; width: 100%; aspect-ratio: 2 / 3; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: 0 4px 16px var(--shadow); }
  .detail-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
  .detail-cover .cover-fallback { font-size: clamp(2rem, 8vw, 3.5rem); }
  .book-detail-main { min-width: 0; padding-top: .25rem; }
  .book-detail-main > .source-badge { margin-bottom: .9rem; }
  .detail-copy-row { display: flex; min-width: 0; align-items: start; gap: .45rem; }
  .detail-copy-row.secondary { align-items: center; margin-top: .15rem; }
  .detail-title { max-width: 820px; margin: 0; overflow-wrap: anywhere; font-size: var(--text-display); font-weight: var(--weight-strong); letter-spacing: -.03em; line-height: 1.12; }
  .detail-original, .detail-author { margin: 0; overflow-wrap: anywhere; color: var(--muted); }
  .detail-original { font-size: var(--text-title); }
  .detail-author { margin-top: .3rem; font-size: var(--text-lead); }
  .detail-author a { color: var(--foreground); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 3px; transition: text-decoration-color .12s ease; }
  @media (hover: hover) { .detail-author a:hover { text-decoration-color: var(--accent); } }
  .detail-copy-row .copy-btn { flex: none; margin-top: .1rem; }
  /* Hero metadata: a labelled facts grid; genre/subgenre values link into browse. */
  .book-facts { display: flex; flex-wrap: wrap; gap: .9rem 2.2rem; margin: 1.35rem 0 0; padding: 0; }
  .book-facts > div { display: flex; flex-direction: column; gap: .2rem; }
  .book-facts dt { color: var(--subtle); font-size: var(--text-label); font-weight: var(--weight-strong); letter-spacing: .06em; text-transform: uppercase; }
  .book-facts dd { margin: 0; color: var(--foreground); font-size: var(--text-title); font-weight: var(--weight-medium); text-decoration: none; }
  /* Half this row links into browse (Genre, Subgenre, Audience) and half is plain text
     (Status, Length, Updated). At --border the underline was invisible against the
     value, so "2y ago" read as clickable too. The linked values carry the rose
     underline used for the author line; the plain ones carry no affordance at all. */
  .book-facts dd a { color: var(--foreground); text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--accent) 55%, transparent); text-decoration-thickness: 1.5px; text-underline-offset: 3px; transition: text-decoration-color .12s ease; }
  @media (hover: hover) { .book-facts dd a:hover { text-decoration-color: var(--accent); } }
  /* The Source cell that links out to Qidian gets no styling of its own: it is a link in
     this grid like the genre and audience ones, so it takes the same foreground colour and
     the same red underline. Only its target differs. */
  .detail-author-zh { color: var(--muted); font-weight: var(--weight-normal); }
  /* Judge-a-book numbers as a tidy tile grid in the Stats section. */
  .book-stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); gap: .55rem; }
  .book-stat { display: flex; flex-direction: column; gap: .2rem; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface-raised); padding: .65rem .75rem; }
  .book-stat-val { font-size: 1.125rem; font-weight: var(--weight-strong); line-height: 1.05; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
  .book-stat-lbl { color: var(--subtle); font-size: var(--text-label); font-weight: var(--weight-strong); letter-spacing: .05em; text-transform: uppercase; }

  /* Honours: charts a book currently ranks on. A clean neutral pill carrying a
     solid rank "coin" — gold/silver/bronze medals for 1–3, the site's near-black
     primary otherwise (same coin the browse rows use) — beside a readable label,
     with the genre channel trailing in muted. */
  .honours-badges { display: flex; flex-wrap: wrap; gap: .5rem; margin: 0; padding: 0; list-style: none; }
  .honour-badge { display: inline-flex; align-items: center; gap: .4rem; border: 1px solid var(--border); border-radius: var(--radius-pill); padding: .26rem .72rem .26rem .28rem; background: var(--surface-raised); font-size: var(--text-caption); line-height: 1; transition: border-color .15s ease, background .15s ease; }
  @media (hover: hover) { .honour-badge:hover { border-color: color-mix(in srgb, var(--foreground) 22%, var(--border)); background: var(--surface); } }
  .honour-rank { display: inline-grid; place-items: center; min-width: 1.2rem; height: 1.2rem; padding: 0 .3rem; border-radius: var(--radius-pill); background: var(--primary); color: var(--primary-foreground); font-size: var(--text-label); font-weight: var(--weight-strong); font-variant-numeric: tabular-nums; letter-spacing: -.01em; }
  .honour-label { color: var(--foreground); font-weight: var(--weight-medium); }
  .honour-channel { color: var(--muted); font-weight: var(--weight-medium); }
  /* Bright metallic medal coins (same crisp fills in both themes, dark same-hue
     text for contrast) — brighter than the muted rankings numeral tokens. */
  /* Tag chips (own section above the synopsis) reuse the site meta-chip, linking into browse. */
  .tag-row { display: flex; flex-wrap: wrap; gap: .45rem; margin: 0; padding: 0; list-style: none; }
  .tag-row .meta-chip { font-size: var(--text-caption); padding: .34rem .72rem; font-weight: var(--weight-medium); text-transform: none; border-radius: var(--radius-pill); border-color: var(--border); color: var(--foreground); background: var(--surface-raised); transition: border-color .12s ease, color .12s ease, background .12s ease; }
  @media (hover: hover) { .tag-row .meta-chip:hover { border-color: var(--input); color: var(--foreground); background: var(--surface); } }
  .catalogue-notice { max-width: 720px; margin: 1.15rem 0 0; border-left: 3px solid var(--primary); padding-left: .8rem; color: var(--muted); font-size: var(--text-body); }
  /* Novel-page rhythm standardised to the site type scale (was oversized). */
  .detail-section { margin-top: 2.75rem; }
  .detail-section-head { margin-bottom: .9rem; }
  /* A section title with an action opposite it. Its own row rather than flex on
     .detail-section-head, because that head also carries a .section-note underneath and
     making the head itself a flex row would pull the note up alongside the title on every
     page that has one. Baseline-aligned so the link sits on the title's line, not its box's. */
  .section-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: .75rem; }
  .section-head-row .section-title { min-width: 0; }
  .section-head-row .text-link { flex: none; font-size: var(--text-body); white-space: nowrap; }
  /* Sized-down count beside a section title (e.g. how many booklists feature this book). */
  .section-count { margin-left: auto; color: var(--subtle); font-size: var(--text-body); font-weight: var(--weight-medium); font-variant-numeric: tabular-nums; letter-spacing: 0; }
  .reading-copy { max-width: none; margin: 0; color: var(--foreground); font-size: var(--text-title); line-height: 1.7; white-space: pre-line; }
  .reading-copy p { margin: 0; }
  /* Expandable long text: line-clamp with a centered Show more/less toggle, and the
     "Load more" list control. Line counts (--clamp) are tuned per context + viewport. */
  .clampable { display: -webkit-box; overflow: hidden; -webkit-box-orient: vertical; -webkit-line-clamp: var(--clamp, 6); }
  /* Keep display: -webkit-box when expanded so measuring clamped vs full height is
     an exact line-count delta (switching to block adds a few px → false positives). */
  .clampable.is-expanded { overflow: visible; -webkit-line-clamp: 9999; }
  .expandable-toggle, .loadmore-btn { display: block; margin: .7rem auto 0; background: none; border: 0; color: var(--muted); font-size: var(--text-caption); font-weight: var(--weight-medium); cursor: pointer; }
  /* The class display:block would otherwise defeat the [hidden] attribute → keep hidden toggles hidden. */
  .expandable-toggle[hidden], .loadmore-btn[hidden] { display: none; }
  @media (hover: hover) { .expandable-toggle:hover, .loadmore-btn:hover { color: var(--foreground); } }
  .loadmore-btn { margin-top: 1rem; border: 1px solid var(--border); border-radius: var(--radius); padding: .45rem 1.1rem; }
  @media (hover: hover) { .loadmore-btn:hover { background: var(--surface); } }
  .reading-copy.clampable { --clamp: 8; }
  .comment-body.clampable { --clamp: 5; }
  .booklist-page .charts-lede.clampable { --clamp: 6; }
  .curator-note p.clampable { --clamp: 4; }
  /* From-source recommendations: a horizontal, drag-scrollable poster shelf. */
  /* The vertical padding is what the hover lift moves into. This row clips on Y
     (overflow-y: hidden, so a lifted cover cannot spill onto the section above), which
     means the ~10px of travel and the shadow under it have to be inside the box or
     they are simply cut off. margin-top drops by exactly what padding-top gains, so
     the row sits where it always did and nothing below it moves. */
  .book-shelf { display: flex; gap: .9rem; margin-top: .45rem; padding-top: .8rem; padding-bottom: 1rem; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; cursor: grab; touch-action: pan-x pan-y; overscroll-behavior-x: contain; }
  .book-shelf::-webkit-scrollbar { display: none; }
  .book-shelf.is-dragging { cursor: grabbing; }
  .shelf-card { display: flex; flex: none; width: 128px; flex-direction: column; gap: .5rem; -webkit-user-drag: none; }
  /* Raised only while hovered. Without it the lifted cover is painted under the cards
     that follow it in the DOM — the one it is supposed to be standing in front of. */
  .shelf-card:hover { position: relative; z-index: 2; }
  /* The settle. This is the SLOW half, and it is the whole effect: a cover takes
     ~440ms to come back down, decelerating the whole way, so sweeping the pointer
     across a rail leaves a trail of covers still falling behind it. The rise is set
     separately below and is roughly four times faster — that asymmetry is what makes
     it read as piano keys rather than as a row of things scaling. Symmetric timing
     settles as fast as the pointer moves, so there is never more than one card in
     motion and the wave never appears. */
  .shelf-cover { position: relative; width: 100%; aspect-ratio: 2 / 3; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface); box-shadow: 0 2px 8px var(--shadow); transition: border-color .14s ease, transform .44s cubic-bezier(.22, 1, .36, 1), box-shadow .44s cubic-bezier(.22, 1, .36, 1); }
  /* Hovering a cover lifts it out of the row, the way a card comes up out of a hand.
     The neighbours stay put — the whole effect is one card rising, which reads as
     picking something up rather than as the row rearranging itself.
     Two properties, transform and box-shadow, both compositor-only, so a rail of 20
     covers costs nothing to animate and nothing reflows. Only the cover moves, never
     the title beneath it: type sliding around under the art is what makes this kind of
     thing feel cheap. */
  @media (hover: hover) {
    /* The strike. 130ms against the settle's 440ms — a key goes down under the finger
       and comes back up on its own.
       A rise and nothing else: no scale, no focus ring, no border change. Scaling
       makes the cover contend with its neighbours for space and reads as a card being
       inspected; moving it straight up reads as a key being struck, which is the whole
       point. The shadow deepens only because that is what tells the eye the cover left
       the surface rather than slid along it. */
    .shelf-card:hover .shelf-cover {
      transform: translateY(-10px);
      box-shadow: 0 12px 20px var(--shadow);
      transition: transform .13s cubic-bezier(.2, .85, .3, 1), box-shadow .13s ease-out;
    }
  }
  .shelf-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; -webkit-user-drag: none; }
  .shelf-cover .cover-fallback { display: grid; place-items: center; width: 100%; height: 100%; font-size: 1.6rem; color: var(--subtle); }
  /* Reserve two lines for the title so the author line never shifts card to card. */
  .shelf-title { display: -webkit-box; min-height: 2.6em; overflow: hidden; font-size: var(--text-body); font-weight: var(--weight-medium); line-height: 1.3; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
  .shelf-author { overflow: hidden; color: var(--muted); font-size: var(--text-caption); line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; }
  /* "In booklists" carousel: the exact listing booklist-card, on a drag-scroll shelf.
     Cards are narrower here, so cap the cover deck to 4 so it doesn't overflow. */
  .booklist-shelf .booklist-card { flex: none; width: min(300px, 82vw); }
  .booklist-shelf .booklist-card img { -webkit-user-drag: none; }
  .booklist-shelf .bl-cover:nth-child(n+6) { display: none; }
  .source-card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .8rem; }
  .source-card { display: flex; min-width: 0; flex-direction: column; border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-raised); padding: 1rem; }
  .source-card-head { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
  .source-identity, .source-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .65rem; margin: 1rem 0 0; }
  .source-identity div, .source-stats div { min-width: 0; }
  .source-identity dt, .source-stats dt { color: var(--muted); font-size: var(--text-label); font-weight: var(--weight-medium); }
  .source-identity dd, .source-stats dd { margin: .15rem 0 0; overflow-wrap: anywhere; font-size: var(--text-body); font-weight: var(--weight-medium); }
  .source-freshness { margin: 1rem 0 0; color: var(--subtle); font-size: .7rem; margin-top: auto; }
  /* One comment per row; text sized to match the synopsis reading copy. */
  .comment-list { display: flex; flex-direction: column; gap: .7rem; margin: 0; padding: 0; list-style: none; }
  .comment-card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-raised); padding: 1rem 1.1rem; }
  .comment-card h3 { margin: 0 0 .4rem; font-size: var(--text-title); font-weight: var(--weight-medium); }
  .comment-body { margin: 0; color: var(--foreground); font-size: var(--text-title); line-height: 1.7; white-space: pre-line; }
  .comment-card footer { display: flex; justify-content: space-between; gap: 1rem; margin-top: .7rem; color: var(--subtle); font-size: var(--text-caption); }
  .comment-card footer span { display: inline-flex; align-items: center; gap: .3rem; }
  /* The site has one accent and this is what it is for. #ed4956 was Instagram's
     heart red, a second accent nothing else on the site used. */
  .comment-like .ico-heart { width: 14px; height: 14px; color: var(--accent); }

  .page-header { max-width: 820px; margin-bottom: 2rem; }
  .page-header > .source-badge { margin-bottom: .8rem; }
  .page-header .lede { margin-bottom: 0; }
  /* Booklists — cards with a fanned cover deck (the list's top books). */
  .booklist-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 1rem; margin-top: 1.75rem; }
  .booklist-card { display: flex; min-width: 0; flex-direction: column; gap: .95rem; border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface-raised); padding: 1.1rem; transition: border-color .14s ease, box-shadow .14s ease, transform .14s ease; }
  @media (hover: hover) { .booklist-card:hover { box-shadow: 0 4px 16px var(--shadow); } }
  .bl-covers { display: flex; padding-left: 2px; }
  .bl-cover { position: relative; flex: none; width: 46px; aspect-ratio: 2 / 3; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); box-shadow: 0 1px 5px var(--shadow); transition: margin-left .16s ease; }
  .bl-cover:not(:first-child) { margin-left: -18px; }
  .bl-cover:nth-child(1) { z-index: 8; } .bl-cover:nth-child(2) { z-index: 7; } .bl-cover:nth-child(3) { z-index: 6; } .bl-cover:nth-child(4) { z-index: 5; }
  .bl-cover:nth-child(5) { z-index: 4; } .bl-cover:nth-child(6) { z-index: 3; } .bl-cover:nth-child(7) { z-index: 2; } .bl-cover:nth-child(8) { z-index: 1; }
  @media (hover: hover) { .booklist-card:hover .bl-cover:not(:first-child) { margin-left: -12px; } }
  .bl-cover img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
  /* bl-body grows so the meta row pins to the bottom, aligned across cards. */
  .bl-body { display: flex; flex: 1; min-width: 0; flex-direction: column; }
  /* Two lines whether the title needs them or not. A carousel of cards whose titles
     are one line and two by turns puts every description and every stat cluster at a
     different height, and the eye reads that as the cards being different things rather
     than the same thing with different text. Reserving the space costs one blank line on
     the short ones and buys a straight line across all of them. */
  .bl-body h2 { display: -webkit-box; margin: 0; min-height: calc(2 * 1.35em); overflow: hidden; font-size: var(--text-lead); font-weight: var(--weight-medium); line-height: 1.35; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
  /* On the carousel shelf, match the lighter title scale of the other home cards. */
  .booklist-shelf .bl-body h2 { font-size: var(--text-title); font-weight: var(--weight-medium); }
  .bl-desc { display: -webkit-box; margin: .4rem 0 0; min-height: calc(2 * 1.55em); overflow: hidden; color: var(--muted); font-size: var(--text-body); line-height: 1.55; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
  /* Subject chips sit between the description and the numbers: what the list is comes
     before how popular it is, because it is what decides whether to open it. */
  /* The listing row. It reuses .browse-row's grid, swapping the single cover column for
     a deck of them — a booklist's identity is the books in it, and one cover would be an
     arbitrary pick from the set. */
  /* A booklist row is not a book row. A book is a title and some numbers, which fits on
     one line; a list is a title, what it is about, and a sentence of why — so the row is
     taller and the parts are stacked, rather than everything crammed onto the title's
     line. The rule below each one does the separating, so the extra height reads as
     structure instead of drift. */
  /* Its own areas, not .browse-row's. A book row is two rows deep — title block over
     synopsis — and a booklist row inheriting that grid picked up an empty second row
     under every list, plus the row-gap above it, which is the slab of dead space that
     appeared beneath each row on this page and nowhere else. A list row is one band:
     deck, text, figures. */
  ol.booklist-list .booklist-row { grid-template-columns: 160px minmax(0, 1fr) auto; grid-template-areas: "covers book stats"; gap: 1.25rem; align-items: start; padding: 1.05rem .5rem; }
  .booklist-row .bl-row-covers { grid-area: covers; }
  .booklist-row .chart-book { grid-area: book; }
  .booklist-row .chart-stats { grid-area: stats; }
  ol.booklist-list > li + li { border-top: 1px solid var(--border); }
  @media (hover: hover) { ol.booklist-list .booklist-row:hover { background: var(--surface); } }
  .bl-row-covers { display: flex; padding-top: .1rem; }
  .bl-row-cover { flex: none; width: 48px; height: 68px; border-radius: var(--radius-sm); overflow: hidden; background: var(--surface); box-shadow: 0 0 0 1px var(--background); }
  /* Overlapped rather than spaced: the stack reads as one object — a list — instead of
     four unrelated thumbnails. */
  .bl-row-cover + .bl-row-cover { margin-left: -22px; }
  /* Five is what the column holds: 40px plus four at 22px of exposed edge. The query
     fetches eight for the detail deck, and without this cap the extra three ran past the
     column and under the title. */
  .bl-row-cover:nth-child(n+6) { display: none; }
  .bl-row-cover img { width: 100%; height: 100%; object-fit: cover; }
  .bl-row-cover-empty { background: var(--surface); }
  /* The title gets room to wrap to a second line; the browse row's single-line ellipsis
     truncates list names badly, since they are sentences rather than titles. */
  .booklist-row .chart-book { display: flex; flex-direction: column; gap: .4rem; min-width: 0; }
  /* No size of its own: a booklist row title is the same rank of thing as a book title
     on the rankings or browse rows, so it takes the same --text-title they do. It was
     1rem, which put it two steps up the scale from every other row on the site. What it
     does change is wrapping — list names are sentences, and the single-line ellipsis
     those rows use truncates them mid-thought. */
  .booklist-row .chart-book strong { white-space: normal; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; line-height: 1.35; }
  .booklist-row .chart-meta { flex-wrap: wrap; gap: .3rem .4rem; }
  /* The timestamp leaves the chip row: a date beside a row of labels reads as another
     label. On its own line under them it reads as what it is. */
  .bl-row-time { display: block; color: var(--subtle); font-size: var(--text-caption); }
  /* The stat block is a column of its own, not text trailing off the end of the row:
     each figure gets the same width and sits right-aligned, so 33 and 4 and 12.4k line
     up down the page instead of ragging against the edge. Aligned to the top with the
     title, because a row's height is set by how much description it has and centring
     made the numbers drift by a few pixels on every row. */
  .booklist-row .chart-stats { align-self: start; gap: 1.75rem; padding-top: .15rem; }
  /* Updated is styled exactly as the counts beside it — same size, same weight, same
     colour, same right edge. It is one of the three facts about the list, and dressing
     it differently made the cluster look like two things and a footnote. Wide enough for
     "11mo ago", and nowrap so it never breaks across the label. */
  .booklist-row .chart-stat { min-width: 68px; align-items: flex-end; }
  /* nowrap only — the size is .chart-stat-val's, the same figure size the rankings and
     browse clusters use. */
  .booklist-row .chart-stat-val { white-space: nowrap; }

  .bl-tags { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .6rem; }
  /* The kind is a plain fact about the list, like its genre — it gets the ordinary chip
     rather than the brand accent, which is reserved for things that rank. */
  .bl-kind { font-weight: var(--weight-medium); }
  /* On the detail page every chip is a link into the filter, so they take the hover the
     rest of the site's chips have. */
  @media (hover: hover) { .booklist-tags a.meta-chip:hover { border-color: var(--input); background: var(--surface); color: var(--foreground); } }
  /* The card reuses the row stat cluster, but a card is not a row: the labels sit under
     the values rather than beside them, and the group wraps instead of scrolling. */
  /* Pinned to the foot of the card by the auto margin, so the figures sit on one line
     across the row even where a card has no description at all. */
  .bl-stats { flex-wrap: wrap; gap: .3rem 1.1rem; margin-top: auto; padding-top: .7rem; }
  .bl-stats .chart-stat { align-items: flex-start; min-width: 0; }

  /* Filters. The panel, the sort/order row, the applied-filter chips and the two-column
     tag picker are all the browse page's, reused rather than restyled — a reader who
     has filtered one list should not have to learn a second control. Only the metric
     row below is particular to booklists. */
  /* Sort and direction take the same grid as the controls below them, so the panel
     reads as one set of five rather than two narrow selects above a full-width row.
     Browse keeps its own flex sizing here — it has a search box on that line, which
     these do not. */
  .bl-filters .filter-top { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: .75rem .9rem; margin-top: .9rem; }
  .bl-filters .filter-sort, .bl-filters .filter-order { flex: none; }

  /* One grid for every control: as many across as the width allows, reflowing to two
     on a phone. auto-fit rather than a fixed count, so the row fills the panel instead
     of leaving the right half of a desktop empty. */
  .bl-filter-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); align-items: end; gap: .75rem .9rem; }
  .bl-field { display: flex; min-width: 0; flex-direction: column; gap: .3rem; }
  /* flex: none, not a basis. .bl-field stacks its label above its control, so a
     flex-basis here would set the control's HEIGHT — which rendered every metric filter
     as a 150px-tall box with the value floating in the middle of it. The width comes
     from the grid column now. */
  .bl-field .select, .bl-field .cs { flex: none; width: 100%; }
  /* Actions close the panel rather than heading it: the controls above are what a
     reader works through, and the button that submits them belongs after. No rule above
     them — the gap already separates them, and a line inside a bordered panel is one
     border too many. */
  .bl-filter-actions { display: flex; justify-content: flex-end; gap: .5rem; margin-top: 1.1rem; }
  /* The tag picker's own selects fill their column, unlike the fixed-width metric ones
     beside them: an option here is a subject name of unpredictable length. */
  /* The book filter sits above the tag columns and is the same shape as one of them:
     a label, a combobox, and the chip it produces. Narrower than full width because a
     single novel's title is short and a full-bleed input reads as a search box for the
     page rather than a filter field. */
  /* margin-bottom as well as top: the block ends in a chip row, and the next thing down
     is the "Min books" label, which sat directly against it. The gap matches the one
     .bl-tagfilter opens below, so the three groups in this panel are evenly spaced. */
  .bl-bookfilter { margin-top: 1.1rem; margin-bottom: 1.25rem; display: grid; gap: .4rem; max-width: 420px; }
  .bl-bookfilter > .field-label { display: block; color: var(--muted); font-size: var(--text-label); font-weight: var(--weight-medium); }
  .bl-tagfilter { margin-top: 1rem; }

  .bl-meta { display: flex; flex-wrap: wrap; gap: .3rem 1rem; margin-top: auto; padding-top: .8rem; color: var(--subtle); font-size: var(--text-caption); font-variant-numeric: tabular-nums; }
  /* Booklist detail header: long titles get the full width; Books/Followers read as a
     compact inline strip under the title, and the description spans the whole row. */
  /* The list's own header: title left, sort right, on the baseline they share. The
     sort used to sit on its own line above the list with no heading beside it, which
     read as a stray control rather than as this section's. */
  .booklist-listhead { display: flex; align-items: center; justify-content: space-between; gap: 1rem; margin: 1.75rem 0 .85rem; }

  /* Booklist sort control — same label + select treatment as the browse filters
     (muted micro-label, 165px select) so it reads as part of the same system.
     Hidden until JS enables it, so without JS the curator's order simply stands. */
  .booklist-sortbar { display: flex; align-items: center; justify-content: flex-end; gap: .55rem; }
  .booklist-sortbar .field-label { color: var(--muted); font-size: var(--text-label); font-weight: var(--weight-medium); letter-spacing: .01em; }
  /* Pin the width on the custom dropdown too, not just the native select it
     replaces: .cs is a flex item with no width of its own, so it sized to the
     selected label and the control jumped about as you changed the sort. */
  .booklist-sortbar .select, .booklist-sortbar .cs { flex: 0 0 165px; width: 165px; }
  .booklist-sortbar[hidden] { display: none; }
  /* The note beside a capped count. A "+" invites the question "where did the rest go";
     this answers it in place rather than making a reader ask. Closed it is a 14px glyph
     next to the number, so the header still reads as a number. */
  /* The glyph leads the number rather than trailing it: after "10k+" it read as part of
     the figure, and the eye had to pass the thing it was about to reach the thing that
     explains it. */
  .count-note { position: relative; display: inline-block; margin-right: .35rem; vertical-align: middle; }
  .count-note > summary { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: var(--radius-pill); color: var(--subtle); cursor: pointer; list-style: none; }
  .count-note > summary::-webkit-details-marker { display: none; }
  @media (hover: hover) { .count-note > summary:hover { color: var(--foreground); } }
  .count-note > summary:focus-visible { outline: 0; box-shadow: 0 0 0 3px color-mix(in srgb, var(--focus) 18%, transparent); }
  .count-note .ico-info { width: 14px; height: 14px; }
  /* Right-anchored: the count sits at the right edge of its header, so a left-anchored
     panel would hang off the page. */
  .count-note-body { position: absolute; z-index: 60; top: calc(100% + .45rem); right: 0; width: min(280px, 78vw); border: 1px solid var(--border); border-radius: var(--radius-lg); background: var(--surface-raised); padding: .7rem .8rem; box-shadow: 0 14px 40px var(--shadow); color: var(--muted); font-size: var(--text-caption); font-weight: var(--weight-normal); line-height: 1.55; text-align: left; letter-spacing: 0; text-transform: none; }
  .count-note-body p { margin: 0; }
  .count-note-body p + p { margin-top: .45rem; }
  .count-note-body a { color: var(--foreground); text-decoration: underline; }

  .booklist-metastrip { display: flex; flex-wrap: wrap; gap: .3rem 1.5rem; margin: .8rem 0 0; }
  .booklist-metastrip div { display: flex; align-items: baseline; gap: .45rem; }
  .booklist-metastrip dt { color: var(--subtle); font-size: var(--text-label); font-weight: var(--weight-strong); letter-spacing: .06em; text-transform: uppercase; }
  .booklist-metastrip dd { margin: 0; font-size: var(--text-title); font-weight: var(--weight-strong); font-variant-numeric: tabular-nums; }
  .booklist-page .charts-lede { max-width: none; white-space: pre-line; }
  .booklist-lede-wrap { margin-bottom: 1.4rem; padding-bottom: 1.4rem; border-bottom: 1px solid var(--border); }

  /* Booklist entries reuse the browse row; the curator note sits full-width below each
     row as an editorial note — a rose accent rule, no card, normal text. */
  .booklist-entries > li { padding-bottom: .55rem; }
  .curator-note { margin: .4rem 0 .9rem; border: 0; border-left: 2px solid var(--accent); border-radius: 0; background: none; padding: .1rem 0 .1rem .95rem; }
  .curator-note span { display: block; margin-bottom: .3rem; color: var(--subtle); font-size: var(--text-label); font-weight: var(--weight-strong); letter-spacing: .05em; text-transform: uppercase; }
  .curator-note p { margin: 0; color: var(--foreground); font-size: var(--text-body); line-height: 1.65; white-space: pre-line; }
  .stat-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .8rem; margin: 1.75rem 0 0; }
  .stat-card { border: 1px solid var(--border); border-radius: var(--radius); background: var(--surface-raised); padding: 1rem; }
  .stat-card dt { color: var(--muted); font-size: var(--text-caption); font-weight: var(--weight-medium); }
  .stat-card dd { display: grid; gap: .35rem; margin: .8rem 0 0; }
  /* Same count numeral as the browse/rankings header stat — one treatment per role,
     rather than this page inventing a bigger, tighter-tracked one of its own. */
  .stats-value { font-size: var(--text-stat); font-weight: var(--weight-strong); line-height: 1.1; font-variant-numeric: tabular-nums; }
  .stats-definition { color: var(--muted); font-size: var(--text-label); line-height: 1.45; }
  /* Section spacing matches the book page's .detail-section, and the section heading
     is scaled down the same way so it does not rival the page title. */
  .stats-section { margin-top: 2.75rem; }
  .table-wrap { max-width: 100%; overflow-x: auto; border: 1px solid var(--border); border-radius: var(--radius); }
  .data-table { width: 100%; min-width: 520px; border-collapse: collapse; background: var(--surface-raised); font-size: var(--text-body); }
  .data-table th, .data-table td { border-bottom: 1px solid var(--border); padding: .75rem 1rem; text-align: left; }
  .data-table thead th { background: var(--surface); color: var(--muted); font-size: var(--text-label); font-weight: var(--weight-strong); text-transform: uppercase; }
  .data-table tbody tr:last-child > * { border-bottom: 0; }
  .reading-page { max-width: 720px; }
  .reading-page .reading-lede { color: var(--muted); font-size: var(--text-lead); line-height: 1.7; }
  .reading-page h2 { margin: 2rem 0 .6rem; font-size: 1.1rem; }
  .reading-page > p:not(.eyebrow):not(.reading-lede) { color: var(--muted); line-height: 1.75; }

  /* Empty / not-found / error states — one component, site-wide. See partials/state.html.
     It replaces three: .empty-state (a centred bordered card), .error-state (the same
     card, left-aligned) and .inline-empty (a dashed box on the book page). All three
     were containers, and this design has no card shells — the rankings page, which is
     its seed, draws every region with an eyebrow and a title and nothing around them.
     So a state is built from those same parts at those same sizes, left-aligned like
     every other block of text on the site. */
  .state { max-width: 62ch; padding: 2rem 0; }
  /* A whole-page state (404, 500) is the page: it gets the vertical room a page header
     would have had, so the browser does not open on a sentence pinned to the top edge. */
  .state-page { display: flex; min-height: 38vh; flex-direction: column; justify-content: center; padding: 1rem 0 3rem; }
  /* The brand rose, matching .charts-eyebrow. It used to be grey, which made the state
     the one place on the site where the eyebrow was not the accent. */
  .state-eyebrow { display: block; margin: 0 0 .45rem; color: var(--accent); font-size: var(--text-label); font-weight: var(--weight-strong); letter-spacing: .09em; text-transform: uppercase; }
  .state-title { margin: 0; color: var(--foreground); font-size: 1.35rem; font-weight: var(--weight-strong); letter-spacing: -.03em; line-height: 1.2; }
  .state-page .state-title { font-size: var(--text-display); font-weight: var(--weight-strong); letter-spacing: -.03em; line-height: 1.1; }
  .state-body { margin: .55rem 0 0; max-width: 52ch; color: var(--muted); font-size: var(--text-title); line-height: 1.5; }
  .state-actions { display: flex; flex-wrap: wrap; gap: .65rem; margin-top: 1.15rem; }

  .ico { width: 13px; height: 13px; flex: none; }
  .ico-star { color: var(--rank-first); }
  .ico-save, .ico-views, .ico-like, .ico-rec, .ico-clicks, .ico-flower { color: var(--muted); }
  .ico-like { width: 14px; height: 14px; flex: none; }
  .ico-caret { width: 8px; height: 8px; flex: none; }
  .copy-icon { width: 15px; height: 15px; }
  .copy-btn { display: inline-grid; width: 32px; height: 32px; place-items: center; border: 0; border-radius: var(--radius-sm); background: transparent; color: var(--muted); }
  @media (hover: hover) { .copy-btn:hover, .copy-btn.is-copied { background: var(--surface); color: var(--primary); } }
  .copy-btn.is-copy-failed { background: color-mix(in srgb, var(--danger) 12%, transparent); color: var(--danger); }
  .copy-btn .ic-check { display: none; }
  .copy-btn.is-copied .ic-copy { display: none; }
  .copy-btn.is-copied .ic-check { display: block; }
  .copy-status { position: fixed; right: 1rem; bottom: 1rem; z-index: 120; max-width: min(24rem, calc(100vw - 2rem)); border: 1px solid var(--border); border-radius: var(--radius); padding: .7rem .9rem; background: var(--foreground); color: var(--background); font-size: var(--text-body); box-shadow: 0 12px 30px var(--shadow); opacity: 0; pointer-events: none; transform: translateY(.5rem); transition: opacity .14s ease, transform .14s ease; }
  .copy-status:not(:empty) { opacity: 1; transform: translateY(0); }

  @media (max-width: 1024px) {
    /* Search collapses to an icon; nav sits between brand and the icon buttons. */
    .header-inner { grid-template-columns: auto minmax(0, 1fr) auto auto; }
    .desktop-search { display: none; }
    .search-toggle { display: grid; }
    .nav-scroller { justify-content: flex-start; }
    /* Tapping search overlays a bar on the navbar row (no height change / shift); it
       expands from the right (the search icon) on open and collapses back on close. */
    .site-header.search-open .mobile-search,
    .site-header.search-closing .mobile-search { display: flex; align-items: center; gap: .4rem; position: absolute; inset: 0; z-index: 60; background: var(--background); padding: 0 calc(var(--gutter) / 2); }
    .site-header.search-open .mobile-search { animation: searchExpand .32s cubic-bezier(.22, 1, .36, 1); }
    .site-header.search-closing .mobile-search { animation: searchCollapse .24s cubic-bezier(.4, 0, .9, 1) forwards; }
    @keyframes searchExpand { from { clip-path: inset(0 0 0 100%); opacity: .35; } to { clip-path: inset(0 0 0 0); opacity: 1; } }
    @keyframes searchCollapse { from { clip-path: inset(0 0 0 0); opacity: 1; } to { clip-path: inset(0 0 0 100%); opacity: .2; } }
    .book-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .book-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .filter-bar { grid-template-columns: repeat(3, minmax(0, 1fr)); }
    .filter-query { grid-column: span 2; }
    .source-card-grid { grid-template-columns: 1fr; }
    .booklist-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  }
  @media (max-width: 700px) {
    /* iOS Safari auto-zooms a focused field whose text is under 16px, so standalone
       fields get 16px. The header search is standalone — nothing sits beside it to
       be measured against — and the bare-element rule covers anything unscoped.
       (Both need naming: the rule carries almost no specificity, so every scoped
       input rule outranked it and the fields stayed at 14px, which is why tapping
       search still zoomed before.)
       The browse panel is deliberately NOT here. Its inputs sit in a row with the
       custom selects, which are buttons rendering their label at 13px, so 16px made
       "Search tags to include…" visibly a size larger than "Any status" next to it.
       Those keep the panel's 13px scale and rely on the viewport's maximum-scale=1
       for the zoom instead — the same thing RandomTranslator does, and it suppresses
       the focus zoom without touching pinch-zoom. */
    input, select, textarea, .input, .select,
    .global-search input, .searchbox input { font-size: 16px; }
    /* Match the panel chrome to the compact scale of the rows below it. */
    .browse-filters { padding: .9rem 1rem; border-radius: var(--radius-lg); }
    .filter-head { margin-bottom: .8rem; }
    .filter-grid { gap: .75rem .7rem; }
    .filter-advanced { margin-top: .9rem; }
    /* Single-row navbar; the nav items live behind the hamburger menu. */
    .header-inner { grid-template-columns: minmax(0, 1fr) auto auto auto; grid-template-areas: "brand search theme menu"; width: min(var(--content), calc(100% - var(--gutter))); min-height: 58px; gap: .1rem; padding: .3rem 0; }
    .brand-wordmark { grid-area: brand; font-size: var(--text-lead); }
    .search-toggle { grid-area: search; width: 44px; height: 44px; }
    .theme-toggle { grid-area: theme; width: 44px; height: 44px; }
    .nav-toggle { grid-area: menu; display: grid; width: 44px; height: 44px; }
    /* Two levels on a phone, and only the FIRST one is a bar.
       Level one is the source list — one chip per source — and it arrives the way the
       search bar does: wiping in along the navbar row from the right, same keyframes
       and same curve as .mobile-search, so the two things that can open in this 44px
       strip behave like one control instead of two idioms. inset stops short of the
       hamburger so it stays uncovered and keeps working as the toggle.
       Level two is NOT a bar. A source's sections used to wipe across the same strip as
       a second level, and that is what this replaces: `< Fanqie  Rankings  Browse
       Booklists` had to fit a ~44px row minus the hamburger, ran out of room on a
       narrow phone, and its overflow-x never rescued it — a strip whose end is flush
       with the screen edge gives no sign there is anything to swipe to, so the links
       past the fold read as cut off rather than as scrolled away.
       So level two is the desktop popover, unchanged: the base .nav-menu rules already
       describe it, and this block only sizes it for a thumb. Nothing to lay out
       horizontally means nothing to run out of room. */
    .nav-scroller { display: none; }
    .site-header.nav-open .nav-scroller,
    .site-header.nav-closing .nav-scroller {
      display: flex; align-items: center; justify-content: flex-end;
      position: absolute; inset: 0 46px 0 0; z-index: 58;
      background: var(--background);
    }
    .site-header.nav-open .nav-scroller { animation: searchExpand .32s cubic-bezier(.22, 1, .36, 1); }
    .site-header.nav-closing .nav-scroller { animation: searchCollapse .24s cubic-bezier(.4, 0, .9, 1) forwards; }
    .site-header.nav-open .main-nav,
    .site-header.nav-closing .main-nav { gap: .1rem; }

    /* Every menu opens leftward here, not just the last one. The chips are packed
       against the hamburger at the right of the strip, so the space a panel can grow
       into is always to the LEFT of the chip that opened it; anchoring the first chip's
       menu to its left edge instead sends a 9.5rem panel back toward the screen edge it
       just came from. Right-anchored, the widest menu still lands inside a 320px phone.
       (The desktop rules stay as they are: there the nav is one item in a wide header
       row and a menu growing rightward has the whole header to grow into.) */
    /* Sized for a thumb rather than for a pointer, too: 44px rows, and a panel wide
       enough that a two-word section name is not the thing deciding its width. The
       desktop density is 28px rows, which is right for a cursor and wrong for a finger. */
    .nav-group .nav-menu {
      left: auto; right: 0; transform-origin: top right;
      min-width: 9.5rem; padding: .3rem;
    }
    .nav-group .nav-menu-link { display: flex; align-items: center; min-height: 44px; padding: .55rem .6rem; }
    /* Left as the base rule has it: display: none. The popover hangs BELOW the chip
       that opened it, so — exactly as on desktop — the chip is still on screen and is
       itself the way back to the source list. The button only earns its place in a
       form where the menu covers its own trigger, which this no longer is. */
    .site-main { width: min(var(--content), calc(100% - var(--gutter))); padding: 2rem 0 2rem; }
    .display-title { font-size: 2.25rem; }
    .page-title { font-size: 2rem; }
    .button, .input, .select { min-height: 44px; }
    .home-intro { grid-template-columns: 1fr; align-items: start; padding-top: .5rem; }
    .home-intro-copy { grid-row: auto; }
    .home-search { width: 100%; }
    .source-filter-list { width: 100%; }
    /* Landing page on mobile. */
    /* The hero keeps the wall on a phone — smaller tiles, so two strips still read as a
       wall rather than two rows of posters — and the scrim tightens with the copy. */
    .home-hero { padding: 4.6rem 0 4.2rem; }
    .hero-cover { width: 48px; height: 72px; margin-right: 5px; border-radius: var(--radius-sm); }
    .hero-copy::before { inset: -2.1rem -1.4rem; filter: blur(22px); }
    .home-stats { grid-template-columns: repeat(3, 1fr); gap: 1.1rem .8rem; }
    /* Mobile: genres scroll horizontally as a carousel again — and only here does the
       rail clip a chip, so the trailing fade lives inside this block. Above 700px the
       row wraps, and a horizontal mask would fade the end of every line. */
    .home-genres { justify-content: flex-start; flex-wrap: nowrap; overflow-x: auto; touch-action: pan-x pan-y; overscroll-behavior-x: contain; padding-bottom: .2rem; -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); mask-image: linear-gradient(to right, #000 calc(100% - 24px), transparent); }
    .home-genres.is-rail-end { -webkit-mask-image: none; mask-image: none; }
    .home-body { gap: 1.9rem; margin-top: 2.1rem; }
    .section { margin-top: 2.5rem; }
    .section-head, .section-header, .library-header, .results-head { align-items: start; }
    /* One heading scale for every section on mobile (the book page already used this);
       at 1.25rem the headings towered over the 14px "View chart →" beside them. */
    .section-title { font-size: 1.0625rem; }
    .section-head { margin-bottom: .75rem; }
    /* Carousel cards: at 128px only two-and-a-half covers fit a 390px screen, so the
       shelves read as oversized posters rather than a shelf. 104px + a tighter gap
       puts three covers on screen with the fourth peeking, which is what makes a
       horizontal shelf legible as scrollable. */
    .book-shelf { gap: .65rem; margin-top: 1rem; }
    .shelf-card { width: 104px; gap: .4rem; }
    .shelf-card-rich { width: 112px; }
    .shelf-cover { border-radius: var(--radius-sm); }
    .shelf-cover .cover-fallback { font-size: 1.25rem; }
    .shelf-rank { top: .3rem; left: .3rem; min-width: 1.3rem; height: 1.3rem; padding: 0 .28rem; }
    .shelf-title { min-height: 2.6em; font-size: var(--text-caption); }
    .shelf-author, .shelf-time { font-size: var(--text-label); }
    /* The booklist cards ran to 82vw — nearly the whole screen for one card. */
    /* The deck loses its later covers on a phone — four 30px covers plus their overlap
       is a third of the width, which the title needs more. */
    /* On a phone the deck goes above the text rather than beside it. Sharing the width
       left the covers at 34px and the title at half a screen, which made both worse; a
       full-width deck can show more of the books — which is what the row is advertising
       — and the text below gets the whole line. */
    /* The areas have to be redeclared, not just the columns: .browse-row's mobile rule
       lays its children out by name into "cover book" / "cover stats", and those
       assignments still apply to a booklist row. Changing only the column count left
       every child resolving to the same cell, stacked on top of each other. */
    /* Two things share the top line — the deck of covers and the two figures — and the
       text takes the whole width beneath them. Stacking all three left-aligned left the
       right half of the first line empty and the numbers stranded at the bottom of a
       loose pile; pairing them gives the row a header of what it is and how big it is,
       then a body. */
    ol.booklist-list .booklist-row { grid-template-columns: auto minmax(0, 1fr); grid-template-areas: "covers stats" "book book"; gap: .75rem .75rem; padding: 1rem .5rem; }
    .booklist-row .bl-row-covers { grid-area: covers; }
    .booklist-row .chart-book { grid-area: book; }
    .bl-row-covers { padding-top: 0; }
    .bl-row-cover { width: 46px; height: 64px; }
    .bl-row-cover + .bl-row-cover { margin-left: -14px; }
    /* Three, not five: the figures beside the deck need the room more than a fourth
       and fifth cover do, and three is enough to say "this is a list of books". */
    .bl-row-cover:nth-child(n+4) { display: none; }
    .booklist-row .chart-book strong { font-size: var(--text-title); }
    /* Grouped, not spread. space-between pushed the three figures to the far corners of
       whatever room the deck left, which read as three unrelated numbers rather than one
       cluster; a fixed gap keeps them together and anchored to the row's edge. */
    .booklist-row .chart-stats { grid-area: stats; align-self: center; justify-content: flex-end; gap: .85rem; margin-top: 0; padding-top: 0; }
    .booklist-row .chart-stat { min-width: 0; align-items: center; }
    .booklist-row .chart-stat-lbl { letter-spacing: .04em; }

    /* Two per line on a phone. These are short controls with short values, so one per
       line wasted a screen of height; the grid's own minimum keeps them legible. */
    .bl-filter-row { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .6rem .5rem; }
    .bl-field .select, .bl-field .cs { width: 100%; }
    .bl-filter-actions { margin-left: 0; }
    .bl-filter-actions .button { flex: 1; }
    .bl-stats { gap: .3rem .9rem; }
    .booklist-shelf .booklist-card { width: min(268px, 76vw); gap: .75rem; padding: .85rem .9rem; }
    .booklist-shelf .bl-cover { width: 40px; }
    .booklist-shelf .bl-cover:not(:first-child) { margin-left: -15px; }
    /* Most Collected: the list runs the full width on a phone. */
    .chart-pair { grid-template-columns: 1fr; gap: 2rem; }
    .mini-row { gap: .6rem; padding: .3rem .15rem; }
    /* Recently updated: three across, which is what the grid is for — and one row of
       them. A 3-across tile on a 390px screen is 109px wide, so its cover alone is
       164px tall; a second row would cost ~200px to inline three more books that the
       rail used to hide behind a swipe. Same device as the comment list below: the
       markup ships six, the phone shows the first three, and the hidden three are
       never fetched (a display:none lazy image does not load). */
    /* Recent comments: trim the row chrome so the text, not the padding, fills it. */
    .cm-row { gap: .65rem; padding: .7rem .75rem; }
    .cm-cover { width: 36px; height: 48px; }
    .book-grid { grid-template-columns: 1fr; }
    .book-card { grid-template-columns: 88px minmax(0, 1fr); min-height: 150px; }
    .book-title { margin-top: .4rem; }
    .library-header { flex-direction: column; gap: 1rem; }
    .library-count { display: flex; gap: .4rem; align-items: baseline; text-align: left; }
    .filter-bar { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .filter-query { grid-column: 1 / -1; }
    /* Centered identity block on mobile: cover, title, author, facts all centered. */
    .book-detail-header { grid-template-columns: 1fr; justify-items: center; text-align: center; gap: 1rem; }
    .detail-cover { width: min(150px, 44vw); }
    .book-detail-main { width: 100%; padding-top: 0; }
    .detail-copy-row { justify-content: center; }
    .detail-title { font-size: 1.5rem; }
    .detail-author { font-size: var(--text-body); }
    /* Standardise the book-page tiles/chips to the browse/rankings scale on mobile.
       (The section heading is handled by the shared .section-title rule above.) */
    .book-facts { justify-content: center; gap: .7rem 1.4rem; }
    .book-facts dd { font-size: var(--text-body); }
    /* The hero is a centred single column on a phone, and a 34px inline pill sitting in
       it read as a leftover chip rather than the page's one action — so it spans the
       column, matching the .book-stats-grid block below it.
       38px, not the 44px touch-target guideline: at 44 with the larger type it stood
       taller than the stat tiles beside it and read as a banner. The full width already
       makes it an easy target — a 38px bar spanning the column is a far bigger hit area
       than a 44px pill was — so the height can follow the page's scale instead. Type
       stays at --text-body for the same reason. */
    .request-btn { width: 100%; min-height: 38px; margin-top: 1.2rem; padding: .5rem 1.1rem; }
    .book-stats-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: .5rem; }
    .book-stat { padding: .6rem .7rem; gap: .15rem; }
    .book-stat-val { font-size: 1.0625rem; }
    /* Tags + honour badges share one caption-size pill scale on mobile too. */
    .tag-row .meta-chip { font-size: var(--text-caption); padding: .32rem .66rem; }
    .honour-badge { font-size: var(--text-caption); padding: .26rem .64rem .26rem .28rem; gap: .38rem; }
    .honour-rank { min-width: 1.15rem; height: 1.15rem; }
    .book-cta-row { justify-content: center; }
    .detail-section { margin-top: 2.25rem; }
    /* About now shares the site's type at both widths, so a phone only needs the
       rhythm tightened — the copy is the same size it was here. */
    .about-body .detail-section { margin-top: 1.8rem; }
    .about-body .discord-btn { margin-top: .9rem; }
    /* Title and sort stay on one line on a phone: the "Sort" micro-label goes (the
       select's own text says what it does, and it keeps an aria-label for anyone who
       cannot see it) and the control narrows to fit beside the heading. */
    .booklist-listhead { margin: 1.4rem 0 .75rem; }
    .booklist-sortbar .field-label { display: none; }
    .booklist-sortbar .select, .booklist-sortbar .cs { flex: 0 0 132px; width: 132px; }
    /* Fewer clamped lines on narrow screens. */
    .reading-copy.clampable { --clamp: 6; }
    .comment-body.clampable { --clamp: 4; }
    .booklist-page .charts-lede.clampable { --clamp: 5; }
    .curator-note p.clampable { --clamp: 3; }
    .source-identity, .source-stats { grid-template-columns: 1fr 1fr; }
    .comment-list { grid-template-columns: 1fr; }
    .booklist-grid { grid-template-columns: 1fr; }
  }
  @media (max-width: 390px) {
    /* Squeeze the source strip, not the panel it opens: these are the chips on the
       navbar row, which is the only part of the nav still competing for width. */
    .main-nav { gap: .1rem; }
    .nav-link { font-size: var(--text-body); }
    .home-search, .filter-bar { grid-template-columns: 1fr; }
    .filter-query { grid-column: auto; }
    .filter-actions { justify-content: stretch; }
    .filter-actions .button { flex: 1; }
    .book-card { grid-template-columns: 76px minmax(0, 1fr); padding: .65rem; }
    /* Hold three covers per shelf on the narrowest phones too. */
    .shelf-card { width: 96px; }
    .shelf-card-rich { width: 104px; }
    .booklist-shelf .booklist-card { width: min(268px, 82vw); }
    .home-stats { gap: .95rem .7rem; }
    .source-identity, .source-stats { grid-template-columns: 1fr; }
    .booklist-grid, .stat-grid { grid-template-columns: 1fr; }
  }
  @media (prefers-reduced-motion: reduce) {
    *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
  }