/* The Ultimate Fantasy Series Ranking: site styles
   Editorial/encyclopedic redesign (2026-08-11, DEC-0226). Light and dark
   themes are both first-class: every colour is a custom property, set
   once here for light (the default) and overridden under
   `@media (prefers-color-scheme: dark)`. No page ever hardcodes a
   colour outside this file, which is what keeps 1,500+ generated pages
   visually consistent with a single edit here.

   THEME SELECTION (DEC-0230, 2026-08-12): the theme follows ONLY the
   visitor's own system/browser preference, automatically, with no
   manual toggle and nothing written to storage. Earlier (DEC-0226) this
   was done with a `[data-theme]` HTML attribute, set by a small inline
   script in every page's <head> (to avoid a flash of the wrong theme
   before a toggle button's saved choice could apply) and flippable by
   that button. The manual override was removed: with no override to
   restore, there is nothing left for a script to compute or apply
   before paint; `@media (prefers-color-scheme: dark)` directly on
   these variables makes the CSS engine itself pick the right theme
   before first paint, with no JS, no attribute and no flash possible
   by construction. The old anti-flash init script and js/main.js's
   toggle handler were both removed, not kept as dead code; see
   DECISIONS.md for DEC-0230.

   Per-universe visual theming is deliberately OUT of scope here. This
   file defines one uniform base layout/theme pair
   for the whole site, not per-universe skins. */

:root {
  /* Light theme (default): warm paper background, ink-dark text,
     a single deep terracotta/oxblood accent. All pairings below were
     checked against WCAG AA (>=4.5:1 for text, including small badge
     text) against the specific background each is used on. */
  --bg: #faf8f4;
  --bg-panel: #ffffff;
  --bg-panel-2: #f1eee6;
  --ink: #22201c;
  --ink-muted: #6f6a5e;
  --line: #e3ddd0;
  --accent: #8a3324;
  --accent-soft: #b6664f;
  --good: #2f6b41;
  --warn: #8f551a;

  /* Typography: a serif for headings/titles (editorial, print-like),
     a clean system sans for body copy and UI, monospace for
     ids/versions/builds. All are safe, widely-installed system
     stacks, with no external font requests, no added latency, or
     dependency. */
  --serif: Georgia, "Iowan Old Style", "Noto Serif", "Times New Roman", serif;
  --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, "Noto Sans", sans-serif;
  --mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;

  --radius: 5px;
  color-scheme: light dark;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #171613;
    --bg-panel: #1f1e1a;
    --bg-panel-2: #292722;
    --ink: #ece7dc;
    --ink-muted: #a39d8c;
    --line: #3a372e;
    --accent: #e08f5b;
    --accent-soft: #c96a44;
    --good: #6fbd85;
    --warn: #d9a53f;
    color-scheme: dark;
  }
}

* { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 16px;
  line-height: 1.7;
  transition: background-color .15s ease, color .15s ease;
  /* Native form controls (checkboxes, radios, range sliders --
     rankings.html/universes.html's filter panels are the main place
     these show up) otherwise render in the browser's own default blue,
     clashing with the site's warm orange/rust palette -- flagged
     visually by Carlos on his phone, 2026-08-27. accent-color is the
     one-line, no-fallback-needed way to retint them; it's on <body>
     (not a narrower selector) so it inherits everywhere without
     needing to be repeated per page/component, same "one edit here"
     discipline as the rest of this file's theming. */
  accent-color: var(--accent);
}

.wrap { max-width: 1040px; margin: 0 auto; padding: 0 1.5rem; }

/* Header */
header.site {
  border-bottom: 1px solid var(--line);
  background: var(--bg-panel);
  padding: 1.75rem 0 0;
}
header.site h1, header.site .site-title {
  font-family: var(--serif);
  font-weight: 700;
  font-size: 1.75rem;
  margin: 0 0 .25rem;
  letter-spacing: -.01em;
}
header.site h1 a, header.site .site-title a { color: var(--ink); text-decoration: none; }
header.site .tagline {
  color: var(--ink-muted);
  margin: 0 0 1.1rem;
  font-size: .95rem;
  font-style: italic;
}

/* Header masthead art ("Option A", Carlos's pick from a 5-favicon/
   3-header concept gallery, 2026-08-17): a faint fantasy skyline behind
   the title. Built entirely from the vars above (--accent/--accent-soft/
   --ink at low opacity), so the existing `prefers-color-scheme: dark`
   override block re-skins it automatically -- no new colors introduced.
   .site-hero-content sits after .site-hero-art in source order, so the
   title/tagline paint over the art with no z-index needed.
   Nothing here animates; if that ever changes, a
   `prefers-reduced-motion: reduce` guard belongs here first (matching
   this file's standing accessibility discipline elsewhere). */
.site-hero { position: relative; overflow: hidden; }
.site-hero-art {
  /* Carlos, after seeing DEC-0450's more-prominent art (bigger opacity,
     taller max-height, a new gradient panel behind it): "a imagem de
     antes era melhor... reverte a imagem para a anterior" -- reverted
     back to DEC-0449's original, subtler proportions/opacity. Only the
     art changed back; the title/tagline sizing and centering fix from
     DEC-0450 stays as-is, he confirmed the letters are right now. */
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  width: 100%;
  height: 100%;
  max-height: 96px;
  opacity: .55;
  pointer-events: none;
}
.site-hero-content { position: relative; text-align: center; }
/* `header.site .site-hero-content h1`/`.tagline` -- needs to out-specify
   the plain `header.site h1`/`header.site .tagline` rules above (both
   already carry `header.site`), or those rules win the cascade by
   specificity regardless of source order and every size/color change
   here silently does nothing. Learned the hard way: a first pass using
   bare `.site-hero-content h1` compiled fine but never actually
   changed the rendered size. */
header.site .site-hero-content h1, header.site .site-hero-content .site-title { font-size: 2.15rem; }
header.site .site-hero-content .tagline { font-size: 1.05rem; color: var(--accent); }

@media (max-width: 600px) {
  /* The art's viewBox stretches to fill .site-hero's box
     (preserveAspectRatio="none", by design, so it always reaches both
     edges); on a narrow phone the box is relatively taller than on
     desktop, so cap the art shorter rather than let it stretch into a
     tall, distorted band behind the title. Text switches to left-aligned
     here (matching the reference mobile mockup) and shrinks just enough
     to keep the title on one line at a 375px viewport, rather than
     centering a two-line wrap that eats too much vertical space. */
  .site-hero-content { text-align: left; }
  .site-hero-art { max-height: 64px; opacity: .4; }
  header.site .site-hero-content h1, header.site .site-hero-content .site-title { font-size: .98rem; white-space: nowrap; letter-spacing: -.02em; }
  header.site .site-hero-content .tagline { font-size: .85rem; }
}

/* Same condensed hero as the max-width breakpoint above, keyed off
   viewport HEIGHT instead (Carlos, 2026-08-28, second-visit UX audit
   finding #6): a phone turned sideways (e.g. 812x375) never matches
   `max-width: 600px` -- it's plenty wide -- but is just as short on
   vertical room, and the full-size hero measured ~184px there, near
   half of a 375px-tall screen before any real content appeared. The
   two conditions can both match at once on some devices; the rules
   are identical in effect, so that's harmless. Threshold picked at
   500px so ordinary short laptop/tablet windows aren't affected, only
   genuinely landscape-phone-scale heights. */
@media (max-height: 500px) {
  .site-hero-content { text-align: left; }
  .site-hero-art { max-height: 64px; opacity: .4; }
  header.site .site-hero-content h1, header.site .site-hero-content .site-title { font-size: .98rem; white-space: nowrap; letter-spacing: -.02em; }
  header.site .site-hero-content .tagline { font-size: .85rem; }
}

nav.site {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: .5rem;
  border-top: 1px solid var(--line);
}
nav.site ul {
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: .1rem;
  margin: 0;
  padding: 0;
  flex: 1 1 auto;
}
nav.site a {
  display: block;
  padding: .65rem .75rem;
  color: var(--ink-muted);
  text-decoration: none;
  border-bottom: 2px solid transparent;
  font-size: .85rem;
  letter-spacing: .01em;
}
nav.site a:hover { color: var(--ink); }
nav.site a.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }

/* Grouped nav dropdowns (DEC-0227): native <details>/<summary> disclosure
   widgets, with no JS required to open/close; js/main.js only closes other
   groups and handles outside clicks as a nicety. */
nav.site details.nav-group { position: relative; }
nav.site details.nav-group > summary {
  list-style: none;
  cursor: pointer;
  padding: .65rem .75rem;
  color: var(--ink-muted);
  font-size: .85rem;
  letter-spacing: .01em;
  border-bottom: 2px solid transparent;
  user-select: none;
  white-space: nowrap;
}
nav.site details.nav-group > summary::-webkit-details-marker { display: none; }
nav.site details.nav-group > summary::after { content: " \25BE"; font-size: .72em; color: var(--ink-muted); }
nav.site details.nav-group > summary:hover { color: var(--ink); }
nav.site details.nav-group[open] > summary { color: var(--ink); }
nav.site details.nav-group > summary.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
nav.site .nav-drop {
  position: absolute;
  top: 100%;
  left: 0;
  z-index: 30;
  min-width: 14em;
  margin: 0;
  padding: .35rem 0;
  list-style: none;
  background: var(--bg-panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  box-shadow: 0 6px 18px rgba(0, 0, 0, .10);
  display: block;
}
nav.site .nav-drop li { display: block; }
nav.site .nav-drop a {
  display: block;
  padding: .5rem 1rem;
  border-bottom: none;
  font-size: .88rem;
}
nav.site .nav-drop a:hover { background: var(--bg-panel-2); }
nav.site .nav-drop a.active { color: var(--accent); font-weight: 600; }
.nav-secondary-tag { color: var(--ink-muted); font-size: .78em; font-weight: 400; }

/* Groups the global search box as its own flex child of nav.site
   (P1 UX pass, 2026-08-12; theme toggle removed by DEC-0230), alongside
   the <ul> of links -- keeps nav.site's own `justify-content:
   space-between` a simple two-child split (links left, tools right). */
.nav-tools { display: flex; align-items: center; gap: .6rem; flex: 0 0 auto; }

/* Content */
main { padding: 2.5rem 0 3.5rem; }
/* main h1.page-title (2026-08-28, SEO audit): every content page's own
   subject (a universe/book/author name, "Rankings", "Discover", ...)
   used to be a mere <h2> -- every single page on the site (4000+ of
   them) carried the exact same literal <h1> text ("The Ultimate
   Fantasy Series Ranking", from the shared header masthead), which is
   precisely the kind of "every page looks the same to a crawler"
   signal that can make search engines treat near-identical pages as
   not worth indexing individually. The masthead's former <h1> is now a
   `.site-title` <p> (branding, not a heading -- see the header rules
   above) on every page except index.html (where the site's own name
   genuinely IS the page's subject, so it keeps a real <h1> there); this
   rule (styled identically to the old h2, nothing about the page's
   appearance changes) is what the real subject is promoted to instead,
   via a shared `.page-title` class so it applies whether the
   underlying tag is h1 (everywhere but index.html) or h2 (kept as a
   fallback selector for safety, though nothing should render it as h2
   any more). */
main h2, main .page-title {
  font-family: var(--serif);
  font-weight: 700;
  color: var(--ink);
  font-size: 1.55rem;
  margin: 2.5rem 0 1rem;
  padding-bottom: .5rem;
  border-bottom: 2px solid var(--line);
}
main h2:first-child, main .page-title:first-child { margin-top: 0; }
main h3 {
  font-family: var(--serif);
  font-weight: 700;
  color: var(--ink);
  font-size: 1.2rem;
  margin: 1.75rem 0 .6rem;
}
main p { max-width: 46em; }
a { color: var(--accent); text-decoration-color: var(--accent-soft); }
a:hover { text-decoration-thickness: 2px; }

/* UX audit item #12 (2026-08-27): small-text inline links in flowing
   body copy -- prose paragraphs, .fine-print/.notice asides, the
   dossier facts-line author badge, the homepage top-5 preview table,
   the "View full ranking"/"Latest updates" footer-of-card links --
   measured a real 14-17px mobile tap-target height via Playwright at
   375px, below the ~44px WCAG 2.5.5/HIG guidance. Fixed with vertical
   padding on the plain inline <a> (no display change, no font-size
   change): Playwright confirms this DOES expand the real hit-box
   without affecting line-height/reflow, so a link mid-sentence still
   wraps normally and the padding stays visually invisible (no
   background is set on these links). Excludes nav links, buttons,
   sort/view-tab controls and stacked-mobile table-row links, which
   already measured comfortably above target. */
main p a,
main li a,
.fine-print a,
.notice a,
.home-ranking-more a,
.home-status a,
.home-top5 td a,
.status-group .badge a {
  padding: .5rem 0;
}
blockquote {
  border-left: 3px solid var(--accent-soft);
  margin: 1.5rem 0;
  padding: .6rem 1.25rem;
  font-family: var(--serif);
  font-size: 1.15rem;
  color: var(--ink);
  background: var(--bg-panel-2);
}

/* Cards */
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 1rem; padding: 0; margin: 1.5rem 0; list-style: none; }
.card {
  background: var(--bg-panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 1.1rem 1.25rem;
}
.card h3 { margin: 0 0 .3rem; font-size: 1.05rem; }
.card .id { color: var(--ink-muted); font-size: .75rem; font-family: var(--mono); }
.card p { margin: .4rem 0 0; color: var(--ink-muted); font-size: .9rem; }

/* News timeline (news.html only -- DEC-0459). Deliberately its OWN
   classes, not .cards/.card above: those stay a narrow-tile grid for
   index.html/project-spec.html/upcoming.html's short, fixed-size
   content. news.html's own day entries can now run to a dozen bullets
   (2026-08-17 convention change, DEC-0456), which doesn't fit a narrow
   grid tile at all -- Carlos, shown 3 layout options against a
   realistic long entry, picked this one: a connecting vertical line
   with a small dot marking each dated entry, content full-width to its
   right, no card background/border. */
.news-timeline {
  position: relative;
  list-style: none;
  margin: 1.5rem 0;
  padding: 0 0 0 1.6rem;
}
.news-timeline::before {
  content: "";
  position: absolute;
  left: 5px;
  top: .5rem;
  bottom: .5rem;
  width: 2px;
  background: var(--line);
}
.news-entry { position: relative; margin-bottom: 2rem; }
.news-entry:last-child { margin-bottom: 0; }
.news-entry::before {
  content: "";
  position: absolute;
  left: -1.6rem;
  top: .35rem;
  width: 11px;
  height: 11px;
  border-radius: 50%;
  background: var(--accent);
  border: 2px solid var(--bg);
  box-shadow: 0 0 0 2px var(--line);
}
.news-entry .news-date { color: var(--ink-muted); font-size: .8rem; font-family: var(--mono); margin: 0 0 .3rem; }
.news-entry h3 { font-family: var(--serif); font-weight: 700; font-size: 1.1rem; margin: 0 0 .3rem; }
.news-entry ul { margin: .3rem 0 0; padding-left: 1.2rem; }
.news-entry li { margin: .3rem 0; }
.news-entry p.news-subhead { font-weight: 600; margin: .8rem 0 .3rem; color: var(--ink); }

/* Lede + collapsed detail (Carlos, 2026-08-27, UX audit #7): the day's
   h3 title is often already a fine one-line summary, but the bullet
   lists underneath had grown to 30-50 titles on a big canon-extension
   day, burying the handful of ordinary site/UX bullets a returning
   visitor actually cares about. .news-lede is one short plain-language
   sentence or two giving a skimmable sense of that day's changes;
   .news-details wraps the existing news-subhead+<ul> bullet block(s)
   (unchanged in content) behind a native <details>, so the full list
   stays one click away rather than disappearing. Scoped under
   .news-entry so it never touches details.research-details (dossier
   pages' research tail) or .rankings-disclosure (Rankings' filter
   panel) -- three separate <details> treatments, same native-disclosure
   idiom, each with its own class because each sits in a different kind
   of content. */
.news-entry p.news-lede { margin: 0 0 .5rem; }
.news-entry details.news-details { margin: 0; }
.news-entry details.news-details > summary { cursor: pointer; font-weight: 600; color: var(--ink-muted); font-size: .92rem; }
.news-entry details.news-details > summary:hover { color: var(--accent); }
.news-entry details.news-details[open] > summary { margin-bottom: .3rem; }

/* RSS subscribe link on news.html (DEC-0461). Scoped to `.news-header-row`,
   not the bare `main h2` -- lets the "What's New" heading sit inline with
   the pill on the same row instead of `main h2`'s own full-width
   border-bottom rule, same specificity discipline as the header-hero
   title fix (a class-scoped selector reliably out-specifies the bare
   element selector regardless of source order). Carlos was shown 4
   placement options (heading-row link, an intro-notice sentence,
   bottom-of-page, or both) and picked this one. */
.news-header-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  flex-wrap: wrap;
  border-bottom: 2px solid var(--line);
  padding-bottom: .5rem;
  margin: 2.5rem 0 1rem;
}
.news-header-row:first-child { margin-top: 0; }
.news-header-row h2 { margin: 0; padding: 0; border: none; }
.rss-pill {
  display: inline-flex;
  align-items: center;
  gap: .35rem;
  border: 1px solid var(--accent-soft);
  color: var(--accent);
  border-radius: 999px;
  padding: .3rem .8rem;
  font-size: .82rem;
  font-weight: 400;
  text-decoration: none;
  white-space: nowrap;
}
.rss-pill:hover { background: var(--bg-panel-2); }
.rss-icon { width: 15px; height: 15px; flex: 0 0 auto; }

/* Tables */
table { width: 100%; border-collapse: collapse; margin: 1.5rem 0; font-size: .92rem; }
th, td { text-align: left; padding: .6rem .75rem; border-bottom: 1px solid var(--line); vertical-align: top; }
th {
  color: var(--ink-muted);
  font-family: var(--sans);
  font-weight: 600;
  font-size: .78rem;
  letter-spacing: .04em;
  text-transform: uppercase;
  border-bottom: 2px solid var(--line);
}
tr:hover td { background: var(--bg-panel-2); }
td .id { color: var(--ink-muted); font-family: var(--mono); font-size: .8rem; }

/* authors.html column-header sort buttons (js/authors-sort.js,
   Carlos, 2026-08-14) -- styled to read as plain header text (inherits
   th's own color/weight/tracking/case) with just enough affordance
   (cursor, underline on hover/focus) to signal it's clickable.
   universes.html's own sort buttons (js/universes-sort.js, Carlos,
   2026-08-25) share this exact rule rather than duplicating it -- same
   visual treatment, same underlying idiom. */
.authors-sort-btn, .universes-sort-btn {
  font: inherit;
  color: inherit;
  text-transform: inherit;
  letter-spacing: inherit;
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
}
.authors-sort-btn:hover, .authors-sort-btn:focus,
.universes-sort-btn:hover, .universes-sort-btn:focus { color: var(--accent); text-decoration: underline; }

/* universes.html: pagination controls added by js/universes-sort.js
   once data/universes-index.json (the full universe list) has loaded
   (Carlos, 2026-08-25 -- Option 3 of the pagination analysis). Hidden
   by default (the `hidden` attribute set inline by that script) until
   the fetch resolves, so a reader without JS or before the fetch
   completes never sees a control that wouldn't do anything. */
.universes-pager, .authors-pager, .rankings-pager {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin: 1rem 0;
}
.universes-pager button, .authors-pager button, .rankings-pager button {
  font: inherit;
  padding: 0.35rem 0.9rem;
  border: 1px solid var(--accent-soft);
  border-radius: 4px;
  background: none;
  color: inherit;
  cursor: pointer;
}
.universes-pager button:hover:not(:disabled), .authors-pager button:hover:not(:disabled), .rankings-pager button:hover:not(:disabled),
.universes-pager button:focus-visible, .authors-pager button:focus-visible, .rankings-pager button:focus-visible { color: var(--accent); border-color: var(--accent); }
.universes-pager button:disabled, .authors-pager button:disabled, .rankings-pager button:disabled { opacity: 0.5; cursor: default; }
.universes-pager-label, .authors-pager-label, .rankings-pager-label { color: var(--muted, inherit); }

/* rankings.html: a plain section label ("Unranked") marking where the
   sorted ranked block ends and the alphabetical unranked block begins;
   written right before the unranked rows, replacing the former
   "Partially computed" explanatory paragraph (a label, not a
   justification, added 2026-08-12). Not a data row: no hover
   highlight, no filter/tier-view participation (it carries neither
   data-score nor data-total-ratings/data-pub-status). */
tr.rankings-section-row td {
  font-weight: 600;
  color: var(--ink-muted);
  text-transform: uppercase;
  letter-spacing: .04em;
  font-size: .78rem;
  background: var(--bg-panel-2);
}
tr.rankings-section-row:hover td { background: var(--bg-panel-2); }

/* Badges */
.badge {
  display: inline-block;
  padding: .12rem .6rem;
  border-radius: 999px;
  font-size: .75rem;
  font-weight: 600;
  border: 1px solid var(--line);
  color: var(--ink-muted);
  white-space: nowrap;
}
.badge.core { color: var(--accent); border-color: var(--accent-soft); }
.badge.in-progress { color: var(--warn); border-color: var(--warn); }
.badge.completed { color: var(--good); border-color: var(--good); }
.badge.unranked { color: var(--ink-muted); }
.badge.ranked { color: var(--accent); border-color: var(--accent-soft); font-weight: 700; }
.badge.research-view {
  color: var(--ink-muted);
  border-color: var(--line);
  text-transform: uppercase;
  letter-spacing: .04em;
  font-size: .68rem;
  vertical-align: middle;
  margin-left: .5rem;
}

/* Rank-movement indicator (UX audit item #1, 2026-08-27): the small
   ▲/▼/NEW marker rankings.html renders next to each row's rank badge,
   comparing today's rank to the most recent PRIOR daily snapshot in
   data/rank_history.json (see gen_rankings.py's module docstring).
   Deliberately unobtrusive -- small text, no pill/border like .badge --
   and reuses the site's existing warm palette: --good (green in light
   mode, its dark-mode counterpart in dark) for improvement, --warn
   (amber/orange, already used for in-progress status) for a drop, and
   --accent for "NEW" so it reads as a highlight without introducing a
   third hue the palette doesn't already have. */
.rank-move {
  display: inline-block;
  margin-left: .3rem;
  font-size: .72rem;
  font-weight: 700;
  white-space: nowrap;
  vertical-align: middle;
}
/* Carlos, 2026-08-28: reported the marker sitting next to the rank
   badge for single-digit ranks (5-9) but wrapping onto its own line
   for two-digit ranks (10+) -- same markup every row, but the wider
   two-digit ".badge.ranked" pill left too little room in the Rank
   column's natural (unset) width for "NN ▲N" to fit on one line,
   while "N ▲N" did. Pin the column wide enough for the worst case
   (3-digit rank, e.g. "528", plus a 2-digit delta marker) and stop it
   from wrapping at all, so every row reads the same regardless of how
   many digits the rank or the delta have. */
#rankings-table td[data-label="Rank"] {
  white-space: nowrap;
  min-width: 5.4rem;
}
.rank-move.up { color: var(--good); }
.rank-move.down { color: var(--warn); }
.rank-move.new {
  color: var(--accent);
  letter-spacing: .02em;
}

/* `.field-tag` is a small uppercase caption used next to a badge to name
   the field it belongs to (e.g. "Status" on a dossier header, or inside
   compare.html's example-comparison buttons). It used to also label a
   "Maturity" pill next to Status on every dossier; that pill, its
   distinguishing dashed style, and the legend/tooltip explaining the
   difference between the two were all removed 2026-08-12 (no visible
   maturity/"ML#" badge anywhere in ordinary browsing), see
   DECISIONS.md for the entry. `.status-group` remains as a plain
   inline-block wrapper; there is no longer a second field to visually
   distinguish it from. */
.field-tag {
  font-family: var(--sans);
  font-size: .68rem;
  letter-spacing: .03em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin-right: .2rem;
}
.status-group { display: inline-block; margin-right: .6rem; }

.notice {
  background: var(--bg-panel-2);
  border: 1px solid var(--accent-soft);
  border-radius: var(--radius);
  padding: .95rem 1.2rem;
  margin: 1.5rem 0;
  color: var(--ink);
  font-size: .95rem;
}

/* A discreet, one-line disclosure -- the opposite treatment of `.notice`
   above. Used for provenance-style asides that are true and worth stating
   but shouldn't compete with the page's actual content for attention
   (e.g. book.html.j2's "fields without a source are omitted" line, moved
   here from a prominent boxed notice at the top of every one of 1,419
   book pages -- site UX audit, 2026-08-13). No box, no border: just
   small, muted text near the bottom of the page. */
.fine-print { color: var(--ink-muted); font-size: .8rem; margin-top: 1.5rem; }

/* Dossier "Jump to section" nav (UX audit item #4, 2026-08-27): built
   client-side by js/main.js from whichever <h3 id="..."> headings a
   given dossier actually rendered -- see that file's comment. Small and
   unobtrusive, sitting right under the facts line, not a sticky/floating
   element; the goal is a quick way to skip a long dossier's Overview/
   Core Canon/Awards/etc., not a persistent nav bar competing with the
   header. Wraps naturally on narrow screens like the filter-row fix. */
.dossier-toc {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: .5rem .9rem;
  margin: .3rem 0 1.3rem;
  font-size: .85rem;
}
.dossier-toc-label { color: var(--ink-muted); }
.dossier-toc a { white-space: nowrap; }

/* Feedback panels (feedback.html: mailto fallback, DEC-0367; previously
   Netlify Forms, DEC-0227, dead since the Netlify->Cloudflare migration
   left no server-side form handler behind either host). */
.feedback-form {
  background: var(--bg-panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 1rem 1.25rem 1.25rem;
  margin: 1rem 0 2rem;
  max-width: 40em;
}
.feedback-form .req { color: var(--accent); }
.feedback-form .feedback-fields { margin: 0 0 .75rem; padding-left: 1.25rem; }
.feedback-form .feedback-fields li { margin-bottom: .25rem; }

/* Call-to-action button (e.g. support.html's donation link) */
.btn {
  display: inline-block;
  background: var(--accent);
  color: #fff;
  border-radius: var(--radius);
  padding: .6rem 1.15rem;
  font-weight: 700;
  text-decoration: none;
}
.btn:hover { opacity: .88; }

/* Articles (constitution) */
.article { margin: 1.75rem 0; }
.article h3 { margin: 0 0 .35rem; }

/* Decisions index */
.dec-cat { margin: 1.75rem 0; }
.dec-cat h3 { margin: 0 0 .35rem; }
.dec-cat-desc { color: var(--ink-muted); font-size: .9rem; margin: 0 0 .5rem; }
.dec-list { margin: 0; padding-left: 1.25rem; }
.dec-list li { margin: .15rem 0; }
.dec-index-total { color: var(--ink-muted); font-size: .85rem; margin-top: 2rem; }
.article p { margin: 0; color: var(--ink-muted); }

/* Footer */
footer.site {
  color: var(--ink-muted);
  font-size: .82rem;
  padding-bottom: 1.5rem;
}
footer.site .vers { font-family: var(--mono); }
footer.site a { color: var(--ink-muted); }

/* Footer masthead art ("Option A", Carlos's pick from a 2-footer concept
   gallery, 2026-08-17): the same skyline motif as the header, flipped
   vertically and far more subtle, closing the page with the same visual
   identity it opened with.

   `.site-footer-hero` sits INSIDE `.wrap` (mirroring `.site-hero` in the
   header), not directly on the `<footer>` element itself -- the first
   version put the border/art straight on `<footer>`, which is full
   page width with no max-width, so the accent line and skyline
   stretched edge-to-edge while the header's own version (correctly
   nested in `.wrap`) stopped at the content column. Carlos caught this
   ("o cabecalho para no limite da pagina, o footer nao") and it was
   fixed by re-nesting to match, not by adding a max-width override. The
   old full-width 1px border on plain `footer.site` above is dropped
   entirely now that every page uses this hero footer -- the accent
   line below supersedes it; keeping both would have doubled the line. */
.site-footer-hero {
  position: relative;
  overflow: hidden;
  border-top: 3px solid var(--accent);
  padding-top: 1.75rem;
}
.site-footer-art {
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  width: 100%;
  height: 100%;
  max-height: 70px;
  opacity: .35;
  pointer-events: none;
}
footer.site .site-footer-content { position: relative; text-align: center; }
footer.site .site-footer-brand {
  font-family: var(--serif);
  font-style: italic;
  color: var(--accent);
  font-size: .95rem;
  margin: 0 0 .3rem;
}
/* "Support this project" footer link (Option A from a 3-option concept,
   2026-08-17: Carlos picked the plain inline-text version over a pill
   button or a link straight to PayPal, to stay consistent with
   support.html's own "ad-free" framing). Styled like an ordinary inline
   link next to the version stamp, not a button -- deliberately doesn't
   compete visually with the rest of the footer. */
footer.site .footer-support-link {
  color: var(--accent);
  text-decoration: none;
  border-bottom: 1px dotted var(--accent-soft);
}
footer.site .footer-support-link:hover { border-bottom-style: solid; }

@media (max-width: 600px) {
  /* Same reasoning as the header's own breakpoint: the art's viewBox
     stretches to fill its box (preserveAspectRatio="none" by design),
     so cap it shorter on a narrow phone rather than let it stretch. */
  .site-footer-art { max-height: 48px; opacity: .25; }
}

@media (max-width: 640px) {
  nav.site { flex-wrap: wrap; position: relative; }
  nav.site ul { gap: 0; }
  nav.site a { padding: .55rem .4rem; font-size: .78rem; }
  nav.site details.nav-group > summary { padding: .55rem .4rem; font-size: .78rem; }
  /* Floating dropdown on mobile, flush under the word that opened it
     (Carlos, 2026-08-29, fifth iteration -- see DEC-0940/DEC-0941/
     DEC-0942 for the fuller history). Carlos's final ask: the panel's
     left edge should sit exactly at the start of "Explore"/"About",
     never shifted away from it, with a plain one-per-line list (like
     desktop) inside. `js/main.js`'s nav-group IIFE measures the real
     position of whichever summary just opened and pins this panel's
     `left`/`top` there UNSHIFTED, then sets `max-width` (as an inline
     style, so it's omitted here) to whatever room is actually left
     between that fixed point and the screen's right edge -- overflow is
     handled by letting a too-long item label wrap onto a second line
     inside its own row, never by moving the panel away from the word
     above it. No `min-width` here (unlike an earlier draft): sizing to
     content lets the panel wrap naturally instead of forcing a fixed
     width that could exceed the JS-computed `max-width` near the
     screen's right edge and reintroduce the very overflow this exists
     to prevent. `max-height` + `overflow-y: auto` remains a vertical
     safety net for a future taller nav group. See js/main.js's own
     comment for the placement logic itself. */
  nav.site .nav-drop {
    position: fixed;
    /* Overrides the desktop rule's own unconditional `min-width: 14em`
       (above) -- CSS gives `min-width` priority over `max-width` when
       the two conflict, so without this reset, the JS-computed
       `max-width` (set inline, tight against the screen's right edge
       for a summary sitting deep in a narrow phone's row) got silently
       ignored and the panel stayed 14em wide regardless, overflowing
       the very edge the JS was there to respect. Content sizes the
       panel naturally from here (up to the JS's `max-width` cap), which
       is also what lets a too-long label wrap onto a second line
       instead of forcing the whole panel wider than it's allowed. */
    min-width: 0;
    max-height: calc(100vh - 12rem);
    overflow-y: auto;
    z-index: 35;
  }
}

/* Homepage lead line + quick-action tiles (audit 2026-08-20: show what
   the visitor can DO before explaining how the project works; restyled
   from a flex row of plain bordered buttons to a 2x2 grid of larger
   action tiles with a one-line muted description each, Carlos
   2026-08-20 -- the "How it works" cards moved to about.html the same
   pass). Whole tile is the anchor. */
.home-lead { font-size: 1.55rem; font-family: var(--serif); margin: 1.25rem 0 0; }
.home-lead-sub { color: var(--ink-muted); margin: .3rem 0 .25rem; }
/* Differentiator line (Carlos, 2026-09-03 external UX/SEO review): a
   new visitor grasps "what this is" quickly from the lead-sub line
   above, but not "why this ranking is different from an arbitrary
   list" -- that case was previously made only on about.html, several
   clicks deep. One quiet sentence near the top states it plainly
   without duplicating about.html's fuller explanation (which the
   inline link points to). Kept visually secondary to home-lead-sub
   (smaller, no bold) so it reads as a supporting claim, not a second
   headline. */
.home-lead-differentiator { color: var(--ink-muted); font-size: .92rem; margin: 0 0 1rem; }
.home-lead-differentiator a { white-space: nowrap; }
.home-tiles {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: .8rem;
  margin: 1.25rem 0 1rem;
}
.home-tile {
  display: flex;
  align-items: flex-start;
  gap: .7rem;
  padding: 1rem 1.1rem;
  border-radius: var(--radius);
  background: var(--bg-panel);
  color: var(--ink);
  text-decoration: none;
  border-left: 3px solid var(--accent);
  box-shadow: 0 1px 2px rgba(0, 0, 0, .04);
  transition: transform .15s ease, box-shadow .15s ease;
}
.home-tile:hover, .home-tile:focus {
  transform: translateY(-2px);
  box-shadow: 0 4px 10px rgba(0, 0, 0, .08);
}
/* Outline SVG icons (2026-08-28, Carlos: wanted a line-icon look here
   instead of the emoji glyphs previously used) -- currentColor picks
   up .home-tile-icon's own color below, so one color rule covers all
   4 without repeating it per-icon in the inline SVG markup. */
.home-tile-icon { display: inline-flex; width: 1.5rem; height: 1.5rem; margin-top: .1rem; color: var(--accent-soft); }
.home-tile-icon svg { width: 100%; height: 100%; }
.home-tile-title { display: block; font-weight: 700; font-family: var(--serif); font-size: 1.02rem; }
.home-tile-desc { display: block; color: var(--ink-muted); font-size: .87rem; margin-top: .2rem; }
@media (max-width: 560px) {
  .home-tiles { grid-template-columns: 1fr; }
}
@media (prefers-color-scheme: dark) {
  .home-tile { box-shadow: 0 1px 2px rgba(0, 0, 0, .18); }
  .home-tile:hover, .home-tile:focus { box-shadow: 0 4px 12px rgba(0, 0, 0, .35); }
}
.score-context { color: var(--ink-muted); font-size: .85em; font-weight: normal; white-space: nowrap; }
.page-intro { color: var(--ink-muted); margin-top: -.4rem; }

/* Homepage "THE 2026 RANKING" block (DEC-0227): content generated from
   data/universes.json, never hand-written. */
.home-top5 { margin: 1rem 0 .5rem; }
.home-top5 table { margin: .5rem 0 1rem; }
.home-top5 td:first-child { width: 3.2em; }
.home-top5 .score { font-family: var(--mono); font-weight: 700; color: var(--accent); }
.home-ranking-more { font-weight: 600; }
.home-status {
  display: flex;
  flex-wrap: wrap;
  gap: .35rem .9rem;
  align-items: center;
  color: var(--ink-muted);
  font-size: .88rem;
  margin: .75rem 0 1.5rem;
}
.home-status .sep { color: var(--line); }

/* Mobile: stacked "cards" instead of wide tables (DEC-0227). Applied only
   to tables that opt in with class="stacked-mobile" AND whose row
   templates emit data-label attributes on each <td> (rankings.html and
   universes.html). Pure CSS, no JS involved. */
@media (max-width: 700px) {
  table.stacked-mobile thead { display: none; }
  table.stacked-mobile, table.stacked-mobile tbody { display: block; }
  table.stacked-mobile tr {
    display: block;
    background: var(--bg-panel);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    margin: .75rem 0;
    padding: .6rem .9rem;
  }
  /* DEC-0776 (2026-08-25): a row hidden via the `hidden` attribute
     must stay hidden here too. Without this, the `display: block`
     above -- an ordinary author-stylesheet rule -- wins the cascade
     over the browser's own `[hidden] { display: none }` UA rule
     regardless of selector specificity (author origin always beats UA
     origin at equal !important-ness), so a filtered-out row rendered
     as an empty-looking card instead of disappearing on any viewport
     at or below 700px width.

     DEC-0777 (2026-08-25): js/universes-table.js's unified render()
     no longer uses the `hidden` attribute at all -- a row that doesn't
     match the current filter is simply never written into the table,
     so there is nothing to hide. This rule is kept because
     js/rankings-filter.js (rankings.html, the same stacked-mobile
     pattern) still filters by toggling `hidden` on existing rows and
     still needs it; it's just no longer exercised by universes.html
     specifically. */
  table.stacked-mobile tr[hidden] { display: none; }
  table.stacked-mobile tr:hover td { background: transparent; }
  table.stacked-mobile td {
    display: flex;
    flex-wrap: wrap;
    gap: .2rem .6rem;
    align-items: baseline;
    border-bottom: none;
    padding: .18rem 0;
  }
  /* A cell with several co-author <a> links (e.g. a shared-world
     series with N credited authors, rankings.html's Author column)
     has each link as its OWN direct flex-item child -- flexbox only
     wraps a whole text run into one anonymous item, not each inline
     element inside it. Without flex-wrap above, those items stayed in
     a single non-wrapping row and pushed the whole card, and the
     page, into horizontal overflow on narrow/portrait screens
     (Carlos, 2026-08-25, caught on The Forgotten Realms' ~20 authors
     right after rotating his phone to portrait). */
  table.stacked-mobile td::before {
    content: attr(data-label);
    flex: 0 0 7.5em;
    color: var(--ink-muted);
    font-size: .72rem;
    font-weight: 600;
    letter-spacing: .04em;
    text-transform: uppercase;
  }
  table.stacked-mobile td[data-label=""]::before { content: none; }

  /* Compact mobile ranking cards (UX audit fix #4, 2026-08-27): every
     stacked card on rankings.html unconditionally showed all 7 fields
     (Rank, Universe, Author, Status, Books, Weighted avg, Ratings) at
     ~284px tall each -- per the audit, 50 cards/page x 11 pages made
     scrolling the ranking "physically tiring". Collapse #rankings-table's
     cards to a one-line summary (rank, name, rating) by default; the
     remaining fields are revealed on tap via a `.expanded` class
     js/rankings-table.js toggles on the row (delegated click listener
     near the bottom of that file).

     Scoped to #rankings-table only -- universes.html's cards (5 fields,
     no Ratings/Books column) aren't what the audit's #4 finding is
     about and are left exactly as before. Also scoped with :has(td
     [data-label="Rank"]) so the "By tier" lens view's rows (rendered by
     renderLensView() in js/rankings-table.js with plain <td>s and no
     data-label attributes at all, since that view has its own distinct
     column set per lens) fall through to the generic stacked-mobile
     styling above, untouched -- there's nothing here for these
     selectors to match on those rows. */
  #rankings-table.stacked-mobile tr:not(.rankings-section-row):not([hidden]):has(td[data-label="Rank"]) {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    column-gap: .5rem;
    position: relative;
    padding-right: 2.4rem; /* room for the .rankings-card-toggle button */
  }
  /* The properties below deliberately do NOT repeat the :has(td[data-
     label="Rank"]) guard the row-layout rule above uses: :has() adds
     real selector specificity (the spec has it inherit its argument's
     specificity), and the hide-by-default rules a few lines down
     (Author/Status/Books/Ratings -> display:none) would then outrank
     the equal-looking tr.expanded reveal rules that must override them
     -- caught live in a headless-browser check where tapping the
     expand button rotated the chevron but the hidden fields never
     actually reappeared. Every one of these selectors already only
     matches real data-label attributes rankings.html's own row
     templates emit, so the plain #rankings-table.stacked-mobile scope
     is sufficient on its own; the "By tier" lens view's rows (plain
     <td>s, no data-label at all) simply never match any of them. */
  #rankings-table.stacked-mobile td[data-label="Rank"] {
    order: 1;
    flex: 0 0 auto;
    padding: 0;
  }
  #rankings-table.stacked-mobile td[data-label="Universe"] {
    order: 2;
    /* flex-basis 0 (not `auto`), Carlos, 2026-08-28: a long universe
       name ("A Court of Thorns and Roses", "The Hands of the Emperor",
       "Empire of the Vampire"...) that wraps to 2 lines was pushing
       the Weighted avg cell onto its own line below, while a short
       name that fit on one line kept it inline -- same row markup,
       different result depending on name length. Root cause: with
       flex-basis `auto`, the browser sizes this item's hypothetical
       main size off its unwrapped (max-content) text width before
       deciding what fits on the flex line, so a long name looked too
       wide to share a line with Weighted avg even though the name
       itself was about to wrap onto 2 lines anyway. Basis 0 makes
       this item's hypothetical size 0 instead, so line-fitting is
       decided by the actually-available space (via flex-grow only),
       not by how long the name is. */
    flex: 1 1 0%;
    min-width: 0;
    padding: 0;
  }
  #rankings-table.stacked-mobile td[data-label="Weighted avg"] {
    order: 3;
    flex: 0 0 auto;
    padding: 0;
    font-weight: 700;
  }
  #rankings-table.stacked-mobile td[data-label="Rank"]::before,
  #rankings-table.stacked-mobile td[data-label="Universe"]::before,
  #rankings-table.stacked-mobile td[data-label="Weighted avg"]::before {
    /* display: none (not just content: none) so the label's reserved
       7.5em flex-basis doesn't leave a phantom gap on the summary line
       -- content: none alone still generates an empty flex item box. */
    display: none;
  }
  #rankings-table.stacked-mobile td[data-label="Universe"] .rankings-premise {
    display: none; /* genre/audience line only shown once expanded */
  }
  #rankings-table.stacked-mobile td[data-label="Author"],
  #rankings-table.stacked-mobile td[data-label="Status"],
  #rankings-table.stacked-mobile td[data-label="Books"],
  #rankings-table.stacked-mobile td[data-label="Ratings"] {
    display: none;
    order: 4;
    flex-basis: 100%;
  }
  #rankings-table.stacked-mobile tr.expanded td[data-label="Author"],
  #rankings-table.stacked-mobile tr.expanded td[data-label="Status"],
  #rankings-table.stacked-mobile tr.expanded td[data-label="Books"],
  #rankings-table.stacked-mobile tr.expanded td[data-label="Ratings"] {
    display: flex;
  }
  #rankings-table.stacked-mobile tr.expanded td[data-label="Universe"] .rankings-premise {
    display: inline;
  }
  /* .rankings-card-toggle: the actual real, keyboard-focusable button
     js/rankings-table.js's addCardToggles() injects into each row's
     last cell (see that function's own comment for why it's a real
     button appended into a <td>, not a pseudo-element or a bare <tr>
     child). Positioned absolute against the row's own position:
     relative set above, at the row's top-right corner, with a real
     ~2.2rem/~35px touch target -- deliberately not shrunk to icon size,
     since the audit separately flagged undersized (17-21px) mobile tap
     targets elsewhere on the site as its own finding; this one is sized
     to not repeat that mistake. */
  #rankings-table.stacked-mobile .rankings-card-toggle {
    position: absolute;
    top: .35rem;
    right: .35rem;
    width: 2.2rem;
    height: 2.2rem;
    display: flex;
    align-items: center;
    justify-content: center;
    background: transparent;
    border: none;
    border-radius: var(--radius);
    color: var(--ink-muted);
    font-size: .85rem;
    line-height: 1;
    cursor: pointer;
    transition: transform .15s ease;
  }
  #rankings-table.stacked-mobile .rankings-card-toggle:hover,
  #rankings-table.stacked-mobile .rankings-card-toggle:focus-visible {
    background: var(--bg-hover, rgba(0, 0, 0, .05));
  }
  #rankings-table.stacked-mobile tr.expanded .rankings-card-toggle {
    transform: rotate(90deg);
  }
}

/* .rankings-card-toggle only exists/matters for the stacked-mobile
   compact-card view (≤700px); above that width js/rankings-table.js
   still injects the button into the DOM (it doesn't know the
   viewport), so hide it explicitly rather than relying on it going
   unstyled -- an unstyled real <button> inside the Ratings cell would
   otherwise show up as a stray "▸" next to the ratings count on desktop. */
@media (min-width: 701px) {
  .rankings-card-toggle { display: none; }
}

/* Universe comparator (compare.html) */
.compare-picker { display: flex; flex-wrap: wrap; gap: 1rem; margin: 1.5rem 0; }
.compare-picker-col { flex: 1 1 260px; min-width: 220px; }
.compare-picker-col label { display: block; margin-bottom: .3rem; color: var(--ink-muted); font-size: .85rem; }
.compare-picker-col select {
  width: 100%;
  padding: .55rem .6rem;
  font-size: 1rem;
  font-family: var(--sans);
  color: var(--ink);
  background: var(--bg-panel);
  border: 1px solid var(--line);
  border-radius: 4px;
}
.compare-picker-col select:focus { outline: 2px solid var(--accent); outline-offset: 2px; border-color: var(--accent-soft); }
#compare-table td.compare-field { color: var(--ink); font-weight: 600; white-space: nowrap; }
#compare-related { margin: 1.5rem 0; }

/* Compare "visual theatre" pass (Carlos, 2026-09-03, relaying an external
   UX/SEO review): the comparator's data was already solid but read as a
   flat two-column spec sheet. Header badges surface the two most
   decision-relevant facts (rating, current rank) right under each
   universe's name instead of making the reader scan the table for them;
   a lead-cell highlight marks, per row, whichever side has the larger
   parsed number (js/compare.js's NUMERIC_FIELDS) -- deliberately a
   per-row factual cue, never a rolled-up "winner" (see that file's own
   comment: Constitution Articles 3 and 6 rule out a computed verdict).
   Kept subtle -- a left accent bar and a slightly bolder number, not a
   trophy icon or a color usually read as "good vs bad" -- since more
   awards/ratings/books is not inherently better for every reader. */
#compare-table th { position: relative; }
.compare-head-badges { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .4rem; font-weight: 400; }
.compare-head-badge {
  display: inline-block;
  font-family: var(--sans);
  font-size: .78rem;
  line-height: 1.3;
  color: var(--ink-muted);
  background: var(--bg-panel-2);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: .2rem .6rem;
}
.compare-head-badge-rating { color: var(--accent); border-color: var(--accent-soft); }
#compare-table tr.compare-row-numeric td.compare-cell-lead {
  font-weight: 700;
  position: relative;
}
#compare-table tr.compare-row-numeric td.compare-cell-lead::before {
  content: "";
  position: absolute;
  left: 0; top: .2em; bottom: .2em;
  width: 3px;
  background: var(--accent-soft);
  border-radius: 2px;
}

/* Configurable rankings explorer (explore-rankings.html) */
.explore-controls {
  background: var(--bg-panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 1rem 1.15rem;
  margin: 1.5rem 0;
}
.explore-presets { margin-bottom: 1rem; display: flex; flex-wrap: wrap; gap: .5rem; align-items: center; }
.explore-presets-label { color: var(--ink-muted); font-size: .85rem; margin-right: .3rem; }
.explore-preset-btn {
  font-family: var(--sans);
  font-size: .82rem;
  color: var(--ink);
  background: var(--bg-panel-2);
  border: 1px solid var(--accent-soft);
  border-radius: 999px;
  padding: .35rem .85rem;
  cursor: pointer;
}
.explore-preset-btn:hover { color: var(--accent); border-color: var(--accent); }
/* rankings.html's own preset buttons (merged Explore presets, DEC-0876)
   -- identical visual language, distinct class only so js/rankings-
   table.js's querySelectorAll never collides with a page that also
   happens to load js/explore-rankings.js (moot now that file is
   deleted, but keeps the two control sets namespaced regardless). */
.rankings-preset-btn { font-family: var(--sans); font-size: .82rem; color: var(--ink); background: var(--bg-panel-2); border: 1px solid var(--accent-soft); border-radius: 999px; padding: .35rem .85rem; cursor: pointer; }
.rankings-preset-btn:hover { color: var(--accent); border-color: var(--accent); }
.explore-control-row { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem; margin: .6rem 0; }
.explore-control-row label { flex: 0 0 220px; color: var(--ink-muted); font-size: .88rem; }

/* compare.html's empty-state example comparisons (DEC-0262, site-exposure
   review item 9), same pill-button visual language as
   .explore-preset-btn above, rendered as links (not buttons) since they
   navigate to a pre-filled, shareable ?a=..&b=.. URL rather than
   mutating in-page state. */
.compare-examples-list { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .6rem; }
.compare-example-btn {
  display: inline-block;
  font-family: var(--sans);
  font-size: .82rem;
  line-height: 1.45;
  color: var(--ink);
  background: var(--bg-panel-2);
  border: 1px solid var(--accent-soft);
  border-radius: 999px;
  padding: .55rem 1.1rem;
  text-decoration: none;
}
.compare-example-btn:hover { color: var(--accent); border-color: var(--accent); }
.compare-example-btn .field-tag { margin-left: .35rem; margin-right: 0; }
/* On narrow screens the pair name and its field-tag caption commonly
   wrap onto a second line inside the same pill; a full 999px radius
   combined with tight padding crowds that wrapped text right up
   against the curved top/bottom edges (Carlos screenshot report,
   2026-08-30). Give the pill more breathing room and a less extreme
   corner radius specifically at the width where wrapping is likely,
   rather than loosening the padding for every screen size. */
@media (max-width: 600px) {
  .compare-example-btn {
    display: block;
    border-radius: 1.1rem;
    padding: .7rem 1.1rem;
  }
}

/* Row show/hide filters on the official ranking (rankings.html, DEC-0162)
   -- reuses .explore-controls/.explore-control-row's layout under a
   distinct class name, since this is a different feature (filtering
   visible rows, never re-sorting) from the Explore Rankings lenses
   above, even though it looks the same. */
/* rankings.html main table: a one-line premise/subtitle under each
   universe's name, and an Author column (site UX audit, 2026-08-13) --
   sourced from the same structured author data and dossier overview
   text already used elsewhere on the site, so a reader can tell what a
   universe IS without leaving the table. */
.rankings-premise { display: block; color: var(--ink-muted); font-size: .82rem; margin-top: .1rem; }
.rankings-filters { background: var(--bg-panel); border: 1px solid var(--line); border-radius: var(--radius); padding: 1rem 1.15rem; margin: 1.5rem 0; }
.rankings-filter-row { display: flex; flex-wrap: wrap; align-items: center; gap: .6rem; margin: .6rem 0; }
/* Standalone Quick search no-match message on rankings.html (Carlos,
   2026-08-28, second-visit UX audit finding #2) -- see this element's
   own comment in rankings.html/js/rankings-table.js for why it exists
   alongside #rankings-filter-status-msg rather than replacing it. */
.rankings-filter-search-status { margin: -.2rem 0 .6rem; font-size: .85rem; color: var(--ink-muted); }
.rankings-filter-row label { flex: 0 0 220px; color: var(--ink-muted); font-size: .88rem; }
.rankings-filter-row .rankings-filter-checks { display: flex; flex-wrap: wrap; gap: 1rem; }
.rankings-filter-row .rankings-filter-checks label { flex: none; font-size: .9rem; color: var(--ink); display: flex; align-items: center; gap: .35rem; }
/* A row with two label+select pairs (e.g. Subgenre / Audience) must wrap as
   whole pairs, not split a label from its own control onto separate lines --
   this was the concrete bug behind the "confirm 768px behavior" TODO item
   (2026-08-25 UX audit): between the 700px and 1440px breakpoints there was
   no rule keeping a pair together, so "Audience" 's label and its <select>
   landed on different flex-wrap lines. Each pair is its own flex item (and
   its own mini flex row internally), so `.rankings-filter-row`'s wrap now
   only ever breaks between pairs. */
.rankings-filter-row .rankings-filter-pair { display: flex; align-items: center; gap: .6rem; flex: 1 1 260px; min-width: 0; }
.rankings-filter-row .rankings-filter-pair label { flex: 0 0 220px; }
.rankings-filter-row .rankings-filter-pair select { flex: 1 1 auto; min-width: 0; }
/* select elements that sit directly in a .rankings-filter-row (Sort/lens,
   Reading commitment -- unlike Subgenre/Audience above, these aren't
   wrapped in a .rankings-filter-pair) default to flex-shrink:1 but
   min-width:auto, which resolves to their own content width (the
   longest <option> text) rather than 0 -- so when that content width
   is wider than the row has room for next to its 220px label, the
   select can't shrink to fit and (depending on exact content-width vs
   viewport-width margins) can end up overflowing the row/page instead
   of cleanly wrapping onto its own line. min-width:0 here lets it
   shrink or wrap normally like every other control in this panel. */
.rankings-filter-row > select { min-width: 0; }

/* Mobile (Carlos, 2026-08-27, live on his phone): every label above
   reserves a fixed 220px (by design, so label+control stay aligned in
   a multi-column desktop/tablet layout -- see the .rankings-filter-pair
   comment above for the 768px pairing fix this mirrors) -- but on a
   ~360-390px phone that alone consumes most of the row's width,
   leaving too little for the control next to it. Two concrete bugs
   this caused: the Sort/lens <select> (long option text, "Official
   order (weighted average)") pushed past the right edge of the screen
   instead of wrapping; the Subgenre/Audience selects were squeezed
   down to a sliver, truncating their own text to "Any subg"/"Any
   audi". Below this width, stack every label above its control
   instead of beside it, so the control gets the row's full width to
   itself. */
@media (max-width: 640px) {
  .rankings-filter-row:has(> select),
  .rankings-filter-row .rankings-filter-pair {
    flex-direction: column;
    align-items: stretch;
  }
  .rankings-filter-row:has(> select) > label,
  .rankings-filter-row .rankings-filter-pair label {
    flex: 0 0 auto;
  }
  .rankings-filter-row > select,
  .rankings-filter-row .rankings-filter-pair select {
    width: 100%;
  }
}

/* Progressive-disclosure wrapper for rankings.html's merged
   rankings/Explore controls (Carlos, 2026-08-26, DEC-0876) -- same
   native <details> idiom as nav.site details.nav-group and
   details.research-details above, its own class only because it needs
   a bit more breathing room around the summary than either of those. */
.rankings-disclosure { margin: 1.5rem 0; border: 1px solid var(--line); border-radius: var(--radius); padding: .1rem 1.15rem 1rem; background: var(--bg-panel); }
.rankings-disclosure > summary { cursor: pointer; font-weight: 600; padding: .85rem 0; color: var(--ink); }
.rankings-disclosure > summary:hover { color: var(--accent); }
.rankings-disclosure[open] > summary { border-bottom: 1px solid var(--line); margin-bottom: .5rem; }
.rankings-disclosure .rankings-filters { background: none; border: none; padding: 0; margin: .5rem 0 0; }
.rankings-disclosure .explore-presets { margin: 0 0 .5rem; }

/* Reset filters, moved outside .rankings-disclosure (Carlos, 2026-08-27):
   it used to live inside the <details> panel above and was unreachable
   whenever the panel was collapsed -- the common case once a filter/
   sort was already active from a shared URL or a previous visit.
   js/rankings-table.js's updateResetButtonVisibility() shows/hides it
   (the `hidden` attribute) so it only appears while something is
   actually active and disappears again once everything's back to
   default, rather than sitting there year-round for the majority of
   visits that never touch a filter. */
.rankings-filter-reset-standalone {
  display: block;
  font-family: var(--sans);
  font-size: .85rem;
  font-weight: 600;
  color: var(--accent);
  background: var(--bg-panel-2);
  border: 1px solid var(--accent);
  border-radius: 999px;
  padding: .4rem 1rem;
  margin: -.6rem 0 1rem;
  cursor: pointer;
}
/* Carlos, 2026-08-28: the button still showed up on a fresh page load
   with no filter active at all. Root cause: this class's own
   `display: block` above has higher specificity than the browser's
   built-in `[hidden] { display: none }` UA rule, so setting the real
   `hidden` attribute (which js/rankings-table.js's
   updateResetButtonVisibility() does correctly) no longer had any
   visible effect once this class also applied -- confirmed live via
   the DOM: `hidden` was `true` but the computed `display` was still
   `block`. Re-asserting `display: none` under `[hidden]` restores the
   attribute's actual effect without touching the JS logic, which was
   already correct. */
.rankings-filter-reset-standalone[hidden] { display: none; }
.rankings-filter-reset-standalone:hover { background: var(--accent); color: var(--bg); }

/* Rankings tier view (P1 UX pass, 2026-08-12): a second, purely visual
   grouping of the same ranked rows above -- js/rankings-tiers.js. */
.rankings-view-toggle { display: flex; gap: .5rem; margin: 1.2rem 0 .5rem; }
.view-tab {
  font-family: var(--sans);
  font-size: .88rem;
  color: var(--ink-muted);
  background: var(--bg-panel-2);
  border: 1px solid var(--line);
  border-radius: 999px;
  padding: .35rem .95rem;
  cursor: pointer;
}
.view-tab:hover { color: var(--ink); border-color: var(--accent-soft); }
.view-tab.active { color: var(--accent); border-color: var(--accent); font-weight: 600; }
.tier-group { margin: 1.6rem 0; }
.tier-group h3 { display: flex; flex-wrap: wrap; align-items: baseline; gap: .6rem; margin-bottom: .5rem; }
.tier-group-label { font-family: var(--sans); font-size: .85rem; font-weight: 400; color: var(--ink-muted); }
.tier-badge { font-size: .95rem; font-weight: 700; min-width: 1.6em; text-align: center; }
.tier-badge.tier-s { color: var(--accent); border-color: var(--accent); }
.tier-badge.tier-a { color: var(--good); border-color: var(--good); }
.tier-badge.tier-b { color: var(--ink); border-color: var(--line); }
.tier-badge.tier-c { color: var(--warn); border-color: var(--warn); }
.tier-badge.tier-d { color: var(--ink-muted); border-color: var(--line); }
.tier-list { list-style: none; margin: 0; padding: 0; border: 1px solid var(--line); border-radius: var(--radius); overflow: hidden; }
.tier-row {
  display: grid;
  grid-template-columns: 2.2rem 1fr 9rem 6rem 4rem 7rem;
  gap: .6rem;
  align-items: center;
  padding: .5rem .75rem;
  border-bottom: 1px solid var(--line);
  font-size: .9rem;
}
.tier-row:last-child { border-bottom: none; }
.tier-row:nth-child(even) { background: var(--bg-panel-2); }
.tier-row-author { color: var(--ink-muted); font-size: .85rem; }
@media (max-width: 700px) {
  .tier-row { grid-template-columns: 1fr; gap: .25rem; padding: .65rem .75rem; }
  .tier-row > span { display: block; }
}
.explore-control-row select {
  padding: .45rem .55rem;
  font-size: .95rem;
  font-family: var(--sans);
  color: var(--ink);
  background: var(--bg-panel-2);
  border: 1px solid var(--line);
  border-radius: 4px;
  flex: 1 1 260px;
}
.explore-control-row input[type="range"] { flex: 1 1 220px; }
.explore-control-val { min-width: 4.5em; color: var(--accent); font-family: var(--mono); font-size: .85rem; }
#explore-unranked-table { opacity: .9; }

/* Universe relationship graph (universe-graph.html) */
.graph-layout { display: flex; flex-wrap: wrap; gap: 1rem; margin: 1rem 0; align-items: flex-start; }
.graph-canvas-wrap {
  flex: 2 1 560px;
  min-width: 320px;
  background: var(--bg-panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: .5rem;
}
.graph-canvas-wrap svg { width: 100%; height: auto; display: block; }
.graph-edge { stroke-width: 1.6; opacity: .75; }
.graph-node-circle {
  fill: var(--bg-panel-2);
  stroke: var(--accent);
  stroke-width: 1.5;
  cursor: pointer;
}
.graph-node:hover .graph-node-circle, .graph-node:focus .graph-node-circle { fill: var(--accent-soft); }
.graph-node-selected .graph-node-circle { fill: var(--accent); stroke: var(--ink); stroke-width: 2; }
.graph-node-label {
  fill: var(--ink-muted);
  font-family: var(--sans);
  font-size: 8.5px;
  pointer-events: none;
}
.graph-node-selected .graph-node-label { fill: var(--ink); font-weight: 600; }
.graph-panel {
  flex: 1 1 260px;
  min-width: 240px;
  background: var(--bg-panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 1rem 1.15rem;
}
.graph-panel h3, .graph-panel h4 { margin-top: 0; }
.graph-panel-empty { color: var(--ink-muted); font-size: .9rem; }
.graph-panel-count { color: var(--ink-muted); font-size: .85rem; }
.graph-panel-rel-list { list-style: none; margin: 0; padding: 0; }
.graph-panel-rel-list li { border-top: 1px solid var(--line); padding: .6rem 0; }
.graph-panel-rel-list li:first-child { border-top: none; }
.graph-panel-rel-type { color: var(--accent); font-size: .85rem; }
.graph-panel-rel-note { color: var(--ink-muted); font-size: .82rem; margin-top: .25rem; }
.graph-panel-rel-cite { color: var(--accent); font-size: .78rem; margin-top: .25rem; font-family: var(--mono); }
.graph-legend { margin: 1.5rem 0; }
.graph-legend-list { list-style: none; margin: .5rem 0; padding: 0; display: flex; flex-wrap: wrap; gap: .9rem; }
.graph-legend-item { display: flex; align-items: center; gap: .4rem; font-size: .85rem; color: var(--ink-muted); }
.graph-legend-swatch { width: 14px; height: 3px; border-radius: 2px; display: inline-block; }
.graph-legend-note { color: var(--ink-muted); font-size: .82rem; }

/* Publication timeline (publication-timeline.html), rebuilt for the
   Wave F "Fantasy Through Time" views (v1.454): pt-yearly (inline-SVG
   year columns), pt-spans (lifespan rows), pt-genres (subgenre small
   multiples), pt-completion (stacked status bars). Chart colors stay on
   the single accent hue like statistics.html's charts. */
.pt-yearly {
  margin: 1rem 0 .4rem;
  padding: .5rem .5rem .25rem;
  background: var(--bg-panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
.pt-yearly-svg { display: block; width: 100%; height: auto; }

.pt-spans {
  margin: 1rem 0 .5rem;
  padding: .5rem .75rem;
  background: var(--bg-panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
.pt-span-row {
  display: grid;
  grid-template-columns: minmax(9em, 15em) 1fr 4.5em;
  align-items: center;
  gap: .6rem;
  padding: 1px 0;
}
.pt-span-label {
  font-size: .78rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.pt-span-track { position: relative; height: 9px; }
.pt-span-bar {
  position: absolute;
  top: 1px;
  height: 7px;
  border-radius: 3px;
  background: var(--accent-soft);
  border: 1px solid var(--accent);
}
/* `.pt-span-row` sets `display: grid` as an explicit author style,
   which always wins over the UA stylesheet's own `[hidden] { display:
   none }` rule regardless of specificity -- so it was silently
   cancelling the `hidden` attribute js/publication-timeline.js relies
   on to hide the rows past the 75-most-rated default until the "Show
   all" toggle is clicked (same failure shape as the search-dropdown
   fix above, 2026-08-29). This re-asserts `display: none` specifically
   for the `[hidden]` case, at higher specificity, so it wins outright
   without touching the normal shown case. */
.pt-span-row[hidden] { display: none; }
.pt-span-row:hover .pt-span-bar { background: var(--accent); }
.pt-span-ongoing { opacity: .65; }
.pt-span-years { color: var(--ink-muted); font-family: var(--mono); font-size: .7rem; white-space: nowrap; }
.pt-spans-toggle {
  margin: .25rem 0 .5rem;
  padding: .35rem .8rem;
  font: inherit;
  font-size: .82rem;
  color: var(--ink);
  background: var(--bg-panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  cursor: pointer;
}
.pt-spans-toggle:hover { border-color: var(--accent); }

.pt-genres {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: .75rem;
  margin: 1rem 0 .4rem;
}
.pt-genre-card {
  padding: .6rem .75rem .4rem;
  background: var(--bg-panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
.pt-genre-title { margin: 0 0 .4rem; font-size: .85rem; color: var(--ink); }
.pt-genre-strip { display: flex; align-items: flex-end; gap: 2px; }
.pt-genre-col { flex: 1 1 0; display: flex; flex-direction: column; align-items: center; }
.pt-genre-bar { width: 100%; background: var(--accent-soft); border-radius: 2px 2px 0 0; }
.pt-genre-col:hover .pt-genre-bar { background: var(--accent); }
.pt-genre-declabel { color: var(--ink-muted); font-size: .62rem; margin-top: .15rem; white-space: nowrap; }

.pt-completion {
  margin: 1rem 0 .4rem;
  padding: .6rem .75rem;
  background: var(--bg-panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}
.pt-comp-legend { margin-bottom: .5rem; font-size: .78rem; color: var(--ink-muted); }
.pt-comp-legend-item { margin-right: 1rem; white-space: nowrap; }
.pt-comp-swatch {
  display: inline-block;
  width: .75em;
  height: .75em;
  border-radius: 2px;
  vertical-align: baseline;
}
.pt-comp-row {
  display: grid;
  grid-template-columns: 4em 1fr 3em;
  align-items: center;
  gap: .6rem;
  padding: 1.5px 0;
}
.pt-comp-label { color: var(--ink-muted); font-size: .78rem; }
.pt-comp-track { display: flex; height: 12px; }
.pt-comp-seg { height: 100%; }
.pt-comp-seg:first-child { border-radius: 3px 0 0 3px; }
.pt-comp-seg:last-child { border-radius: 0 3px 3px 0; }
.pt-comp-complete { background: var(--accent); }
.pt-comp-ongoing { background: var(--accent-soft); }
.pt-comp-unfinished { background: var(--ink-muted); }
.pt-comp-count { color: var(--ink-muted); font-family: var(--mono); font-size: .72rem; }

.timeline-activity-note { color: var(--ink-muted); font-size: .82rem; margin: 0 0 1.25rem; }
.timeline-decade { margin: 1.75rem 0; }
.timeline-decade h3,
.timeline-decade h4 {
  color: var(--ink);
  border-bottom: 1px solid var(--line);
  padding-bottom: .3rem;
  margin-bottom: .1rem;
}
.timeline-decade-count { color: var(--ink-muted); font-size: .85rem; font-weight: normal; }
.timeline-entries { list-style: none; margin: 0; padding: 0; }
.timeline-entry {
  display: flex;
  align-items: baseline;
  gap: .75rem;
  border-top: 1px solid var(--line);
  padding: .6rem 0;
}
.timeline-entry:first-child { border-top: none; }
.timeline-entry-year {
  flex: 0 0 3.4em;
  color: var(--accent);
  font-family: var(--mono);
  font-weight: 600;
}
.timeline-entry-body { flex: 1 1 auto; }
.timeline-entry-note { color: var(--ink-muted); font-size: .8rem; margin-top: .15rem; }

/* Statistics page (statistics.html) */
.stats-jumpnav {
  display: flex;
  flex-wrap: wrap;
  gap: .35rem .5rem;
  margin: .75rem 0 1.25rem;
}
.stats-jumpnav a {
  display: inline-block;
  padding: .25rem .7rem;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--bg-panel);
  font-size: .82rem;
  text-decoration: none;
  color: var(--ink);
}
.stats-jumpnav a:hover { border-color: var(--accent); color: var(--accent); }
.stats-tiles { display: flex; flex-wrap: wrap; gap: .75rem; margin: 1rem 0 1.5rem; }
.stats-tile {
  flex: 1 1 120px;
  min-width: 110px;
  background: var(--bg-panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: .9rem 1rem;
  text-align: center;
}
.stats-tile-num { color: var(--accent); font-size: 1.6rem; font-weight: 700; font-family: var(--mono); }
.stats-tile-label { color: var(--ink-muted); font-size: .82rem; margin-top: .2rem; }
.stats-bars { display: flex; flex-direction: column; gap: .35rem; margin: .75rem 0 1.25rem; }
.stats-bar-row { display: flex; align-items: center; gap: .6rem; }
.stats-bar-label { flex: 0 0 13em; font-size: .88rem; overflow-wrap: anywhere; }
.stats-bar-track {
  flex: 1 1 auto;
  height: 10px;
  background: var(--bg-panel-2);
  border-radius: 5px;
  overflow: hidden;
}
.stats-bar-fill { height: 100%; background: var(--accent-soft); border-radius: 5px; }
.stats-bar-count { flex: 0 0 4.5em; text-align: right; color: var(--ink-muted); font-size: .85rem; font-family: var(--mono); }
.stats-note { color: var(--ink-muted); font-size: .82rem; margin: -.4rem 0 .5rem; }

/* Bar/column drill-down panels (2026-08-21, Carlos: "cada barra abria
   uma pagina ou algo nem que fosse uma tooltip com os universos" --
   every aggregate bar across statistics.html and publication-
   timeline.html can be clicked/Entered to reveal the actual universes
   behind it, inline, right under the chart. `.stats-bar-item` wraps a
   bar row plus its (initially hidden) panel so the panel lays out as
   its own block instead of a stray flex item inside the row; a bar
   without members renders exactly as before (no wrapper side effect). */
.stats-bar-item { display: flex; flex-direction: column; }
.stats-drill-trigger { cursor: pointer; }
.stats-drill-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.stats-bar-track.stats-drill-trigger:hover .stats-bar-fill,
.stats-bar-track.stats-drill-active .stats-bar-fill { background: var(--accent); }
.stats-drill-panel {
  margin: .3rem 0 .5rem;
  padding: .55rem .75rem;
  border: 1px solid var(--line, var(--ink-muted));
  border-radius: var(--radius);
  background: var(--bg-panel-2);
  font-size: .85rem;
}
.stats-drill-heading { margin: 0 0 .35rem; color: var(--ink-muted); font-size: .8rem; }
.stats-drill-list {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-wrap: wrap;
  gap: .3rem .9rem;
  max-height: 12.5rem;
  overflow-y: auto;
}
.stats-drill-list li { white-space: nowrap; }

/* Publication span next to a Status badge (2026-08-20): a small muted
   "1997\u20132007" / "1995\u2013present" suffix on rankings.html's Status cells
   (.rankings-pub-span) and each dossier's header status group
   (.pub-span). Plain secondary text, never a badge of its own. */
.rankings-pub-span, .pub-span { color: var(--ink-muted); font-size: .82rem; white-space: nowrap; }

/* Inline SVG column charts (DEC-0263, site-exposure review item 10):
   a lightweight, dependency-free chart for the 3 distributions worth
   seeing as a real shape rather than a sorted list of numbers
   (publication status, decade, rating band) -- vanilla SVG built in
   js/statistics.js, no charting library, matching this project's
   no-CDN-script discipline. Single accent hue only (never a multi-hue
   categorical palette here: these bars are identified by their own
   position + direct label, not by colour, so no CVD-safe categorical
   palette is needed at all -- see the dataviz skill's colour-formula
   check, which a same-hue set trivially passes by not applying). */
.stats-chart { margin: .75rem 0 1.25rem; }
.stats-svg-chart { display: block; width: 100%; height: auto; }
.stats-chart-bar { fill: var(--accent-soft); }
.stats-chart-bar:hover { fill: var(--accent); }
.stats-chart-axis { stroke: var(--line); stroke-width: 1; }
.stats-chart-value { fill: var(--ink-muted); font-family: var(--mono); font-size: 11px; }
.stats-chart-cat { fill: var(--ink-muted); font-family: var(--sans); font-size: 11px; }

/* Popularity-vs-quality scatter (statistics.html, 2026-08-20): same
   vanilla-SVG, single-accent-hue discipline as the column charts above
   -- one mark type, identity carried by position + hover title + link,
   never by a categorical palette. Grid lines stay on --line (hairline,
   recede), dots on the accent with a hover step-up, exactly the bars'
   own two-state treatment. */
.stats-scatter-grid { stroke: var(--line); stroke-width: 1; }
.stats-scatter-dot { fill: var(--accent-soft); stroke: var(--accent); stroke-width: .75; opacity: .85; }
.stats-scatter-dot:hover { fill: var(--accent); opacity: 1; }
.stats-scatter-axis-label { fill: var(--ink-muted); font-family: var(--sans); font-size: 11px; }
.stats-superlatives { display: flex; flex-wrap: wrap; gap: 1rem; margin: 1rem 0; }
.stats-superlative {
  flex: 1 1 260px;
  min-width: 240px;
  background: var(--bg-panel);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: 1rem 1.15rem;
}
.stats-superlative h4 { margin-top: 0; color: var(--ink); }
.stats-superlative p { margin-bottom: 0; }

/* Search autocomplete (search.html) */
.search-box { position: relative; max-width: 32em; }
.search-box input {
  width: 100%;
  padding: .6rem .7rem;
  font-size: 1rem;
  font-family: var(--sans);
  color: var(--ink);
  background: var(--bg-panel);
  border: 1px solid var(--line);
  border-radius: 4px;
}
.search-box input:focus { outline: 2px solid var(--accent); outline-offset: 2px; border-color: var(--accent-soft); }
.search-box ul,
nav.site .search-box ul {
  /* Explicit override (Carlos, 2026-08-28): the global nav.site ul rule
     (display: flex, meant for the main menu <ul>) also matches this
     dropdown since it's nested inside <nav class="site">, which flexed
     the group headers ("SERIES"/"AUTHORS"/"BOOKS") and result <li>s
     onto shared rows instead of each taking its own full-width line.
     The nav.site .search-box ul variant matches that rule's own
     specificity (element + class + element) so it reliably wins
     regardless of source order, restoring the intended list layout. */
  display: block;
  position: absolute;
  z-index: 10;
  top: 100%;
  left: 0;
  right: 0;
  margin: .25rem 0 0;
  padding: .25rem 0;
  list-style: none;
  background: var(--bg-panel-2);
  border: 1px solid var(--line);
  border-radius: 4px;
  max-height: 22em;
  overflow-y: auto;
}
/* The rule above sets `display: block` (an explicit author style) to
   win over the `nav.site ul` flex rule -- but an author style also
   always wins over the UA stylesheet's own `[hidden] { display: none }`
   rule, regardless of specificity, so it was silently cancelling the
   `hidden` attribute js/search.js relies on to hide this dropdown
   whenever it has nothing to show. The visible result (Carlos,
   2026-08-29): a thin, empty, rounded bar sitting under the search box
   at all times, on every page, whether or not a search was ever run.
   This re-asserts `display: none` specifically for the `[hidden]` case
   -- adding the `[hidden]` attribute selector gives this rule strictly
   higher specificity than the rule above, so it wins outright, without
   touching the shown-with-results case (no `[hidden]` attribute then)
   at all. */
.search-box ul[hidden],
nav.site .search-box ul[hidden] {
  display: none;
}
.search-box li { padding: 0; }
.search-box li a {
  display: block;
  padding: .5rem .7rem;
  color: var(--ink);
  text-decoration: none;
}
.search-box li a:hover,
.search-box li.active a { background: var(--bg-panel); }
.search-box li.active a { color: var(--accent); }
.search-box .search-type {
  /* Always its own line under the entry name (UI bug, 2026-08-27:
     without this, a short name like "Green Bone Saga" let the context
     span sit inline on the same visual line since nothing forced a
     wrap, while a longer name like "Saga of the Borderlands" wrapped
     to two lines and pushed the context below only by accident of text
     length -- inconsistent layout across otherwise-identical dropdown
     rows, for every entry type (universes, series, authors, books). */
  display: block;
  color: var(--ink-muted);
  font-size: .85em;
}
.search-box .search-more { padding: .5rem .7rem; color: var(--ink-muted); font-size: .85em; }
/* Grouped search results (Wave B, 2026-08-20): js/search.js renders a
   small non-interactive type header (Universes / Authors / Books /
   Series) above each group of hits, and each hit's context line
   (rank + score, universe, "N universes") in a muted span. */
.search-box .search-group-header {
  padding: .45rem .7rem .15rem;
  color: var(--ink-muted);
  font-size: .7em;
  font-weight: 700;
  letter-spacing: .08em;
  text-transform: uppercase;
}


/* Global nav search box (P1 UX pass, 2026-08-12) -- same .search-box
   rules above, just narrower and sized to sit inline in the nav bar
   next to the theme toggle. js/search.js wires it up exactly like
   search.html's own box (see #nav-search-input in templates/_nav.html.j2). */
.nav-search { max-width: 12em; margin: .4rem 0; }
.nav-search input { padding: .4rem .6rem; font-size: .85rem; border-radius: 999px; }
.nav-search ul { max-height: 18em; }

/* Discover page (discover.html, Wave B of the 2026-08-20 audit): five
   mechanical shelves generated by scripts/gen_discover.py. One class
   prefix per feature, per the site-feature convention. */
.discover-shelf { margin: 2rem 0; }
.discover-shelf h3 { margin-bottom: .25rem; }
.discover-blurb { color: var(--ink-muted); font-size: .9em; margin-top: 0; }
/* Discover page reframe (Carlos, 2026-09-03, relaying an external UX/SEO
   review, item #3: position Discover as the site's primary acquisition
   engine -- "what should I read?" -- distinct from Rankings' "what's the
   best?"). Lead paragraph styled like the homepage's own .home-lead-sub
   (muted, slightly smaller) so the two pages' framing reads as one
   consistent voice rather than two different page conventions. */
.discover-lead { color: var(--ink-muted); font-size: 1.05rem; margin: .3rem 0 1.25rem; max-width: 62ch; }
.discover-list { list-style: none; padding-left: 0; }
.discover-list li { padding: .3rem 0; border-bottom: 1px solid var(--border, rgba(128,128,128,.2)); }
.discover-list li:last-child { border-bottom: none; }
.discover-author { color: var(--ink-muted); font-size: .9em; }
.discover-author::before { content: "· "; }
.discover-context { color: var(--ink-muted); font-size: .85em; float: right; }
.discover-see-all { font-size: .9em; }
@media (max-width: 40em) {
  .discover-context { float: none; display: block; }
}

/* "You might also like" context line on every universe dossier
   (gen_similar_universes.py, same Wave B pass). */
.similar-universes-context { color: var(--ink-muted); font-size: .85em; }
.similar-universes-context::before { content: "· "; }

/* Book cover images (Carlos, 2026-08-17): sourced live from the Open
   Library Covers API (covers.openlibrary.org/b/isbn/...), never
   downloaded/stored -- explicitly documented by Open Library itself as
   built for exactly this public-display use, unlike the rating
   platform's own images/branding, which this project never shows (see
   the "no rating platform brand name" guard elsewhere in this project).
   Only rendered when a book has an ISBN on file (~11% of Core Canon as
   of this writing; ordinary bibliographic research gradually fills in
   the rest, same as page_count/first_edition). No image at all -- not a
   broken-image icon or placeholder box -- when a book has no ISBN, so a
   book without a cover looks exactly like every book page looked before
   this feature existed. Option C of 3 mocked-up layouts (two-column,
   cover as a left sidebar, closest to how a library catalog or
   Goodreads/Amazon lays out a book page) -- Carlos's pick over a small
   corner thumbnail or a centered hero image. */
.book-layout {
  display: flex;
  gap: 1.75rem;
  align-items: flex-start;
}
.book-layout .book-content {
  flex: 1 1 auto;
  min-width: 0;
}
/* Record table's key column (Carlos, 2026-08-28): without a fixed
   width, a plain <table>'s first column auto-sizes per-table, so a book
   with no cover image -- whose `.book-content` fills the full `.wrap`
   width instead of being squeezed by a 140px cover sidebar -- gets a
   visibly wider/emptier key column than a book that has a cover, even
   though both tables list the same kind of rows. Capping it keeps the
   Record table's proportions identical whether or not a cover renders. */
.book-content table th:first-child {
  width: 11em;
}
.book-cover {
  flex: 0 0 140px;
}
.book-cover img {
  display: block;
  width: 140px;
  /* Fixed height (not auto), roughly a 2:3 book-cover ratio, plus
     font-size:0 and overflow:hidden (2026-08-17, fixing a real bug
     Carlos caught live): with only `height: auto`, a broken or slow-
     loading image lets the browser's fallback alt-text rendering grow
     the box to fit the wrapped text, showing an oversized, ugly
     placeholder instead of the small, contained "no image yet" look
     this feature is meant to have. `object-fit: cover` keeps a real
     loaded cover filling the box without distortion. */
  height: 210px;
  object-fit: cover;
  border-radius: 3px;
  border: 1px solid var(--line);
  box-shadow: 0 2px 8px rgba(0, 0, 0, .15);
  font-size: 0;
  overflow: hidden;
}
/* Real bug, caught live 2026-08-31 (a "Montego" cover onerror firing
   while the broken-image icon stayed visible NEXT TO the "Sem capa"
   fallback card, instead of being replaced by it): this rule's own
   `display: block` above is an author-stylesheet declaration, which
   always wins over the UA stylesheet's own `[hidden] { display: none }`
   rule regardless of which one is "more specific" in the usual sense --
   an author rule beats a UA rule outright. Setting the `hidden`
   attribute via the cover's own inline `onerror` therefore did nothing
   visually on its own. This re-asserts `display: none` specifically for
   the `[hidden]` case, at higher specificity than the bare rule above,
   so it wins outright. Exact same fix already applied to
   `.cover-fallback-thumb` below when DEC-1005 shipped -- missed here on
   the actual `<img>` itself at the time, the same way `.cover-table
   .cover-thumb` and `li .cover-thumb` below were also missed (fixed
   alongside this one). */
.book-cover img[hidden] {
  display: none;
}
@media (max-width: 640px) {
  .book-layout { flex-direction: column; }
  .book-cover { flex: 0 0 auto; }
  .book-cover img { width: 110px; height: 165px; }
}

/* Visible "no cover" placeholder (Carlos, 2026-08-31, DEC-1005,
   superseding DEC-0469's "hide it completely" rule): `canon_helpers.py`
   `cover_thumb_html()` emits this span as a hidden sibling right after
   every cover `<img>`; that image's own inline `onerror` hides itself
   and un-hides this span instead of removing anything (see that
   function's docstring for why -- a real incident showed a silently
   hidden cover is indistinguishable from a book that never had an ISBN
   at all). Sized to match `.book-cover img` above exactly, at both
   breakpoints, so nothing reflows if the real cover loads a moment
   later (slow network) or after a fix (this Kickstarter-hosted cover's
   own story). A serif "spine" -- the book's own title, plus a small
   "Sem capa" caption -- rather than a generic broken-image icon, so it
   reads as part of this site's own design instead of an error. */
.cover-fallback-detail {
  display: none;
  width: 140px;
  height: 210px;
  border-radius: 3px;
  border: 1px solid var(--line);
  box-shadow: 0 2px 8px rgba(0, 0, 0, .15);
  background: linear-gradient(165deg, var(--bg-panel-2), var(--bg));
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: .6rem;
  padding: 1rem;
}
.cover-fallback-detail:not([hidden]) {
  display: flex;
}
.cover-fallback-spine {
  font-family: var(--serif);
  font-size: .92rem;
  line-height: 1.35;
  color: var(--ink);
}
.cover-fallback-label {
  font-family: var(--sans);
  font-size: .68rem;
  color: var(--ink-muted);
  letter-spacing: .02em;
}
@media (max-width: 640px) {
  .cover-fallback-detail { width: 110px; height: 165px; padding: .7rem; }
  .cover-fallback-spine { font-size: .8rem; }
}

/* Click-to-enlarge lightbox for a book's own cover (Carlos, 2026-08-17):
   the small sidebar image is a `zoomable`-classed <img> with a
   `data-full` attribute (the Open Library `L` size), read by
   js/main.js's click handler, which builds the overlay below. Degrades
   fine without JS: the cursor just hints at a click that does nothing,
   same as any other unhooked cursor style, and the small cover itself
   is unaffected either way. */
.book-cover img.zoomable {
  cursor: zoom-in;
}
.cover-lightbox {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, .82);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 1000;
  cursor: zoom-out;
  padding: 2rem;
}
.cover-lightbox img {
  max-width: 90vw;
  max-height: 90vh;
  border-radius: 4px;
  box-shadow: 0 4px 24px rgba(0, 0, 0, .5);
}

/* Small cover thumbnails inside dossier Core Canon/Expanded tables and
   standalone-work lists (Carlos, 2026-08-17, DEC-0465: "gosto da
   abordagem do small cover thumbnail", his sign-off on the mocked-up
   concept, per his own "em qualquer sítio que um livro seja
   referenciado" instruction). Same live-hotlinked Open Library image as
   a book's own page, just small and inline; empty when the book has no
   ISBN yet, same as everywhere else this feature appears.

   Fixed height + font-size:0 + overflow:hidden, same reasoning as
   `.book-cover img` above -- a broken or still-loading image must stay
   contained at the small placeholder size, never balloon out to fit
   wrapped alt text. */
.cover-table .cover-thumb {
  display: block;
  width: 32px;
  height: 42px;
  object-fit: cover;
  border-radius: 2px;
  border: 1px solid var(--line);
  font-size: 0;
  overflow: hidden;
}
.cover-table th:first-child,
.cover-table td:first-child {
  width: 40px;
  padding-right: 0;
}
li .cover-thumb {
  display: inline-block;
  width: 24px;
  height: 32px;
  object-fit: cover;
  vertical-align: middle;
  border-radius: 2px;
  border: 1px solid var(--line);
  margin-right: .4em;
  font-size: 0;
  overflow: hidden;
}
/* Same real bug as `.book-cover img[hidden]` above, caught the same day
   (2026-08-31): these two rules' own `display` declarations are author
   rules and always win over the UA `[hidden] { display: none }` rule on
   their own, so the broken cover's `onerror` setting `hidden` on the
   `<img>` did nothing visually here either -- the broken-image icon
   stayed on screen next to the "Sem capa"/letter fallback instead of
   being replaced by it. Higher-specificity `[hidden]` overrides below
   fix both small sizes; `.cover-fallback-thumb[hidden]` further below
   already had its own `!important` version of this exact fix (it was
   applied to the fallback SPAN when DEC-1005 shipped) -- this is the
   same fix, just for the real `<img>` itself, missed at the time. */
.cover-table .cover-thumb[hidden],
li .cover-thumb[hidden] {
  display: none;
}

/* Visible "no cover" placeholder for the small thumbnail sizes above
   (Carlos, 2026-08-31, DEC-1005 -- see `.cover-fallback-detail`'s own
   comment for the full "why" and the incident that prompted it). Reuses
   the `cover-thumb` class so it inherits `.cover-table .cover-thumb`'s
   or `li .cover-thumb`'s exact box (width/height/border/radius/margin)
   -- both of those set `font-size: 0` to hide broken alt text, which
   would also hide this placeholder's own letter, so `font-size` and
   the rest of the visible styling are restored here. Just the title's
   first letter, not the full "Sem capa" caption `.cover-fallback-detail`
   shows: there's no room for readable words at 24-32px, and Carlos
   flagged that an accent-bar-only earlier draft read as decoration
   rather than a legible "this has no cover" signal at this size. The
   `[hidden]` rule is `!important` deliberately -- this reuses the same
   `cover-thumb` class the sizing rules above also match, so without an
   unambiguous override the placeholder could show through underneath a
   perfectly good, still-loading image. */
.cover-fallback-thumb {
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--serif);
  font-size: .78rem;
  line-height: 1;
  font-weight: 600;
  color: var(--accent-soft);
  background: var(--bg-panel-2);
}
.cover-fallback-thumb[hidden] {
  display: none !important;
}
/* Font-size cascade bug (Carlos, 2026-09-03, live screenshot: "The
   Adversary" showed a blank box, no letter, on a phone). Root cause:
   this span always carries BOTH `cover-thumb` and `cover-fallback-thumb`
   (see `.cover-table .cover-thumb`/`li .cover-thumb` above and
   `cover_thumb_html()`'s own markup), and `.cover-table .cover-thumb` /
   `li .cover-thumb` set `font-size: 0` (to hide a broken `<img>`'s alt
   text) at TWO-class specificity, which beats this rule's own
   `font-size: .78rem` at one-class specificity regardless of source
   order -- so the fallback letter was being rendered at font-size 0 the
   entire time DEC-1005/1008 believed it was visible. Same exact cascade
   shape as DEC-1008's `display` fix just above (author rule beats author
   rule by specificity, not by position in the file), just never applied
   to `font-size`. `!important` here, matching the `[hidden]` rule right
   above, since this reuses the same two base selectors on purpose and
   nothing else should be able to zero it out again by accident. */
.cover-table .cover-fallback-thumb,
li .cover-fallback-thumb {
  font-size: .78rem !important;
}

/* Dossier reader-first hero + collapsed research tail (Wave C, Carlos's
   2026-08-20 audit): rank + score big under the universe name; the
   investigator material (metrics notice, provenance panel, canon rule)
   folded into a native <details>, the same disclosure element the nav's
   .nav-group already uses. */
/* Dossier "Quick answers" block (Carlos, 2026-09-03, relaying an external
   UX/SEO review, item #2: dossiers as SEO landing pages). Plain list, no
   card/box treatment -- this sits right after the Core Canon/Library
   tables, which already carry the visual weight; the point is scannable
   short answers, not a new decorative element. The question stays bold
   inline (not a separate line) so each item reads as one compact
   Q-then-A unit at a glance, matching how featured-snippet UIs usually
   render this exact shape. */
.dossier-quick-answers { margin: .75rem 0 1.5rem; padding-left: 1.2rem; }
.dossier-quick-answers li { margin: .5rem 0; line-height: 1.5; }
.dossier-quick-answers li strong { color: var(--ink); }

.dossier-hero-rank { margin: -.35rem 0 .4rem; font-family: var(--mono); }
.dossier-hero-rank .rank { font-size: 1.6rem; font-weight: 700; }
.dossier-hero-rank .score { font-size: 1.6rem; font-weight: 700; color: var(--accent); margin-left: .45rem; }
.dossier-hero-rank .score::before { content: "· "; color: var(--ink-muted); font-weight: normal; }
details.research-details { margin: 2rem 0 1rem; border-top: 1px solid var(--line, var(--ink-muted)); padding-top: .6rem; }
details.research-details > summary { cursor: pointer; font-weight: 600; color: var(--ink-muted); }
details.research-details > summary:hover { color: var(--ink); }

/* FAQ accordion (about.html, Carlos, 2026-08-29): each question is its
   own native <details>/<summary> so a visitor can open just the one
   they want instead of scanning a wall of always-expanded prose, same
   native-disclosure idiom as nav.site details.nav-group and
   details.research-details above -- no JS required to open/close.
   js/main.js's own auto-open-on-anchor handling (added the same day)
   still opens the right item first if a link points at one of these by
   id (e.g. about.html#source-tiers from another page). */
.faq-list { margin: 0; }
details.faq-item { border-bottom: 1px solid var(--line); padding: .1rem 0; }
details.faq-item:first-child { border-top: 1px solid var(--line); }
details.faq-item > summary {
  list-style: none;
  cursor: pointer;
  padding: .85rem 0;
  font-weight: 600;
  color: var(--ink);
}
details.faq-item > summary::-webkit-details-marker { display: none; }
details.faq-item > summary::after { content: " \25BE"; float: right; color: var(--ink-muted); font-size: .85em; }
details.faq-item[open] > summary::after { content: " \25B4"; }
details.faq-item > summary:hover { color: var(--accent); }
details.faq-item > p { margin: 0 0 1rem; }

/* Subgenre glossary inside the FAQ's "What do the fantasy subgenre
   labels mean?" answer (about.html, Carlos, 2026-08-30): was one run-on
   paragraph with every subgenre mashed together; now a definition list
   so each subgenre name and its explanation starts on its own line,
   indented consistently under the intro sentence above it. */
dl.subgenre-glossary { margin: 0 0 1rem; padding-left: 1rem; }
dl.subgenre-glossary dt { font-weight: 600; color: var(--ink); margin: .85rem 0 .2rem; }
dl.subgenre-glossary dt:first-child { margin-top: 0; }
dl.subgenre-glossary dd { margin: 0 0 0 1rem; color: var(--ink-muted); }

/* Book Record table's "Original language" flag (2026-08-28, Carlos:
   reported the flag still not showing even after the first fix below
   forced a color-emoji font stack on this cell -- his own machine
   apparently has none of those fonts at all, so no font stack could
   have fixed it). Replaced the Unicode flag-emoji approach with a
   local SVG <img> (canon_helpers.language_flag_img(), assets/flags/),
   which renders regardless of what fonts a visitor's system has. This
   small vertical-align nudge keeps the flag's baseline level with the
   "EN"/"PT"/etc code text beside it. */
.lang-flag {
  vertical-align: -2px;
  border: 1px solid var(--line);
  border-radius: 1px;
}
