/* ============================================================================
   PJSK Viewer — UI styled after Project Sekai: Colorful Stage
   ----------------------------------------------------------------------------
   Design DNA (derived from the official app + colorfulstage.com + pjsekai.sega.jp):
     · Bright, light background — soft sky/cream gradients with confetti motifs
     · Pop-flat surfaces: white cards with very soft shadows + rounded corners
     · Pill-shaped buttons, white with a colored leading icon
     · Triangle (▲) confetti background pattern in pink/cyan/mint
     · Rounded gothic typography (M PLUS Rounded 1c / Zen Maru Gothic)
     · Pink accent (#ff5fb1-ish) used sparingly for emphasis
     · Unit identity via colored "seal" chips on the side of each card
   ============================================================================ */

/* Google Fonts are loaded non-blocking from index.html via the
   rel="preload" + media="print" onload swap pattern. Do NOT add an
   @import here — it would re-introduce render blocking. */

:root {
  /* base palette — light, like the official app */
  --bg-1: #eaf5ff;          /* sky top */
  --bg-2: #fbeaf5;          /* pink bottom */
  --surface: #ffffff;
  --surface-2: #f7f9ff;
  --ink: #1a2240;
  --ink-soft: #58608a;
  --muted: #8b94bb;
  --line: #e2e8f5;
  --line-strong: #cfd6ef;

  /* Glass surface (sticky top bar, sticky bottom nav). Light theme: white-ish
     translucent. Dark theme overrides this in the [data-theme="dark"] block
     below. */
  --glass-bg: rgba(255, 255, 255, 0.78);

  /* Hero card gradient (used by .hero on home + a few other route headers).
     Theme-aware via dark override below. */
  --hero-grad: linear-gradient(135deg, #ffffff 0%, #f4f8ff 100%);

  /* Inline-code surface (used by <code>). Theme-aware. */
  --code-bg: #eef2fb;
  --code-fg: #36406b;

  /* Legacy aliases: a handful of late-added components (episode picker,
   * event-picker hero info) reference --text / --text-soft instead of
   * --ink / --ink-soft. Aliasing here means those rules pick up the
   * dark palette automatically without each call-site needing to be
   * touched. (C17 follow-up: these were silently falling back to their
   * hard-coded hex defaults, which collided with the dark surface.) */
  --text: var(--ink);
  --text-soft: var(--ink-soft);

  --accent: #ff5fb1;        /* PJSK pink */
  --accent-2: #33d1e6;      /* miku cyan */
  --accent-3: #88dd44;      /* fresh green */
  --accent-4: #ffd166;      /* highlight yellow */
  --accent-violet: #884cc2;

  /* official-leaning unit colors */
  --u-light_sound:    #4455dd;  /* Leo/need cobalt */
  --u-idol:           #88dd44;  /* MMJ leaf green */
  --u-street:         #ee1166;  /* VBS hot red */
  --u-theme_park:     #ff9900;  /* WxS goldenrod */
  --u-school_refusal: #884cc2;  /* 25-ji violet */
  --u-piapro:         #33d1e6;  /* VS Miku cyan */
  --u-none:           #8b94bb;

  /* shadows */
  --shadow-sm: 0 1px 3px rgba(34, 44, 90, 0.08), 0 1px 2px rgba(34, 44, 90, 0.04);
  --shadow-md: 0 6px 18px rgba(34, 44, 90, 0.10), 0 2px 5px rgba(34, 44, 90, 0.06);
  --shadow-lg: 0 18px 38px rgba(34, 44, 90, 0.14), 0 6px 12px rgba(34, 44, 90, 0.08);
  --shadow-pop: 0 8px 0 rgba(34, 44, 90, 0.10); /* slight chunky-shadow for pill buttons */

  --radius: 14px;
  --radius-lg: 22px;
  --radius-pill: 999px;

  --font-ui: "M PLUS Rounded 1c", "Zen Maru Gothic", "Hiragino Maru Gothic ProN",
             -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
  --font-display: "Zen Maru Gothic", "M PLUS Rounded 1c", "Hiragino Maru Gothic ProN", sans-serif;
}

/* ----------------------------------------------------------------------------
   Dark theme — applied via <html data-theme="dark"> (default in index.html).
   Overrides variables; component CSS that uses var(--surface), var(--ink),
   etc. automatically picks up dark values. A handful of legacy hardcoded
   gradients (body background overlays, .hero) are re-declared below for the
   dark theme.
   --------------------------------------------------------------------------- */
[data-theme="dark"] {
  /* base palette — deep blue-violet near-black, warm enough to keep the PJSK
     personality without being clinical OLED-black */
  --bg-1: #11132a;          /* deeper top */
  --bg-2: #1e1633;          /* warm violet bottom */
  --surface: #1c2040;       /* card body */
  --surface-2: #232a52;     /* slightly lifted (hovered rows, headers) */
  --ink: #eef1ff;           /* primary text */
  --ink-soft: #b6bee5;      /* secondary text */
  --muted: #8088b4;         /* tertiary / meta */
  --line: #2a3060;          /* hairline borders */
  --line-strong: #3a4280;   /* prominent borders */

  --glass-bg: rgba(20, 22, 50, 0.78);
  --hero-grad: linear-gradient(135deg, #232a52 0%, #1c2040 100%);
  --code-bg: #232a52;
  --code-fg: #d8def5;

  /* shadows — dark theme uses darker, slightly more diffuse drop shadows */
  --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.35), 0 1px 2px rgba(0, 0, 0, 0.22);
  --shadow-md: 0 6px 18px rgba(0, 0, 0, 0.40), 0 2px 5px rgba(0, 0, 0, 0.28);
  --shadow-lg: 0 18px 38px rgba(0, 0, 0, 0.50), 0 6px 12px rgba(0, 0, 0, 0.34);
  --shadow-pop: 0 8px 0 rgba(0, 0, 0, 0.40);
}

/* Dark-theme background overlay: replace the bright sky-pink radial wash
   with a subtle violet/cyan glow over the deep base gradient. */
[data-theme="dark"] body {
  background:
    radial-gradient(1100px 700px at 80% -10%, #1b264d 0%, transparent 60%),
    radial-gradient(900px 700px at -10% 110%, #2b1840 0%, transparent 55%),
    linear-gradient(180deg, var(--bg-1) 0%, var(--bg-2) 100%);
  background-attachment: fixed;
}

/* Reduce confetti opacity in dark mode so it reads as faint ambience, not
   bright noise. */
[data-theme="dark"] body::before,
[data-theme="dark"] body::after {
  opacity: 0.55;
}

/* ---------- reset & globals ---------- */
* { box-sizing: border-box; }
html, body {
  margin: 0; padding: 0;
  color: var(--ink);
  font-family: var(--font-ui);
  font-weight: 500;
  font-size: 15px;
  line-height: 1.55;
  letter-spacing: 0.01em;
  min-height: 100vh;
  background:
    radial-gradient(1100px 700px at 80% -10%, #d8efff 0%, transparent 60%),
    radial-gradient(900px 700px at -10% 110%, #ffe0f0 0%, transparent 55%),
    linear-gradient(180deg, var(--bg-1) 0%, var(--bg-2) 100%);
  background-attachment: fixed;
}

/* Triangle confetti motif drawn with CSS — multiple layered SVG data URIs */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 0;
  background-image:
    /* pink triangle small */
    url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28'><polygon points='14,4 25,24 3,24' fill='%23ff8fc4' opacity='0.32'/></svg>"),
    /* cyan triangle medium */
    url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='44' height='44' viewBox='0 0 44 44'><polygon points='22,6 40,38 4,38' fill='%2333d1e6' opacity='0.22'/></svg>"),
    /* mint triangle large */
    url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='60' height='60' viewBox='0 0 60 60'><polygon points='30,8 54,52 6,52' fill='%2388dd44' opacity='0.18'/></svg>");
  background-position: 6% 12%, 84% 28%, 22% 78%;
  background-repeat: no-repeat;
  background-size: 28px, 44px, 60px;
}
/* second sprinkle layer for richness */
body::after {
  content: "";
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 0;
  background-image:
    url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='20' height='20' viewBox='0 0 20 20'><polygon points='10,3 18,17 2,17' fill='%23ffb74d' opacity='0.25'/></svg>"),
    url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='36' height='36' viewBox='0 0 36 36'><polygon points='18,5 33,31 3,31' fill='%23ff5fb1' opacity='0.18'/></svg>"),
    url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 14 14'><polygon points='7,2 13,12 1,12' fill='%23884cc2' opacity='0.22'/></svg>");
  background-position: 92% 8%, 14% 42%, 68% 88%;
  background-repeat: no-repeat;
  background-size: 20px, 36px, 14px;
}

a { color: var(--accent); text-decoration: none; font-weight: 600; }
a:hover { color: #e23f96; }
img { display: block; max-width: 100%; }

code {
  font-family: ui-monospace, "SF Mono", "JetBrains Mono", monospace;
  background: var(--code-bg); color: var(--code-fg);
  padding: 1px 6px; border-radius: 6px;
  font-size: 0.88em; font-weight: 500;
}

/* ---------- layout ----------
 *
 * C11: the page is laid out as three stacked rows — a slim top app-bar that
 * carries the search input, the main content area, and a bottom in-game-style
 * navigation strip that replaces the old left sidebar. The bottom strip is
 * sticky so it stays visible while the user scrolls through long lists.
 *
 *   ┌─────────────────────────────────────────────────────────────┐
 *   │ #appbar  (search · region chip)                              │  ← sticky top
 *   ├─────────────────────────────────────────────────────────────┤
 *   │                                                              │
 *   │  #app  (main content — full width)                           │
 *   │                                                              │
 *   ├─────────────────────────────────────────────────────────────┤
 *   │ #nav  (brand · Home · Stories · Characters · ...)            │  ← sticky bottom
 *   └─────────────────────────────────────────────────────────────┘
 */
body {
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  position: relative;
}

/* ---------- top header pills (rank/coins style) ---------- */
.top-bar {
  display: flex; align-items: center; gap: 8px;
  margin-bottom: 22px; flex-wrap: wrap;
}
.header-pill {
  background: var(--surface);
  border-radius: var(--radius-pill);
  padding: 6px 16px 6px 8px;
  box-shadow: var(--shadow-sm);
  display: inline-flex; align-items: center; gap: 10px;
  font-weight: 700;
  color: var(--ink);
  font-size: 13px;
}
.header-pill .ico {
  width: 28px; height: 28px;
  border-radius: 50%;
  display: grid; place-items: center;
  background: linear-gradient(135deg, var(--accent-2), var(--accent));
  color: white; font-size: 14px;
}
.header-pill .label { color: var(--muted); font-size: 11px; font-weight: 600; margin-right: 4px; text-transform: uppercase; letter-spacing: 0.08em; }
.header-pill .val { color: var(--ink); font-weight: 800; }

/* ---------- top app-bar (C11) ----------
 * Slim sticky banner that carries the search input + region pill. Sits above
 * #app and scrolls with it; the bottom nav is the persistent chrome.
 *
 * C21 briefly promoted this to position: fixed; C23 reverts that — the bar
 * lives back in the body flex column. Picker pages instead claim more
 * vertical room by subtracting a larger constant in their viewport-fit
 * height formula (see .event-picker height: calc(100vh - 350px)). */
#appbar {
  position: sticky;
  top: 0;
  z-index: 11;
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 12px;
  padding: 12px 24px;
  background: var(--glass-bg);
  backdrop-filter: saturate(140%) blur(10px);
  -webkit-backdrop-filter: saturate(140%) blur(10px);
  border-bottom: 1px solid var(--line);
  box-shadow: 0 1px 0 rgba(34, 44, 90, 0.02);
}
#appbar .topbar-search {
  /* Override the legacy sidebar margin; in the appbar we want a fixed width
   * pill that hugs the right edge of the bar. */
  margin: 0;
  width: clamp(220px, 32vw, 380px);
}
/* C5d: the unified cluster (q + speaker) needs more pill width to keep both
 * facets legible at common viewport sizes. Override the single-pill clamp
 * with a wider one. On viewports below 720px the speaker facet hides via
 * the media query above, so the q-field reclaims the full width and the
 * lower bound (260px) is enough for it alone. */
#appbar .topbar-search.topbar-search-cluster {
  width: clamp(260px, 46vw, 560px);
}
/* C19: region pill now lives in #appbar (right side) immediately to the left
 * of the search input. The shared .region styles below already give it a
 * surface-2 pill look; this just nudges the inner select up a touch and
 * keeps the pill from shrinking when the search box claims its 380px. */
#appbar .region {
  flex-shrink: 0;
  align-self: center;
  /* C5: a touch of left padding so the new global mute button sitting to
   * the left of the region pill has breathing room before the pill's
   * surface-2 background starts. The bar's own `gap: 12px` already keeps
   * a 12px gap; this is purely visual breathing room inside the pill. */
  padding-left: 2px;
}
@media (max-width: 560px) {
  /* On very narrow viewports the label inside the region pill ("REGION") is
   * noisy next to a small select — hide it so the dropdown still fits
   * alongside the search input. */
  #appbar .region label { display: none; }
}

/* ---------- C5: global mute button ----------
 * Lives in #appbar to the left of the region pill. Persists across all
 * routes (the appbar is outside #view). Backed by GlobalAudio — see
 * public/js/global-audio.js + docs/GLOBAL_AUDIO.md.
 *
 * Two visual states driven by [aria-pressed]:
 *   false → currently UN-muted: render the speaker-with-waves icon
 *   true  → currently muted:    render the speaker-with-slash icon
 * The icon SVG is injected as a CSS mask so we can flip it via aria-pressed
 * without touching the DOM each time the state changes.
 */
.global-mute-btn {
  appearance: none;
  -webkit-appearance: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  padding: 0;
  border-radius: 50%;
  background: var(--surface-2);
  border: 1px solid var(--line);
  color: var(--text);
  cursor: pointer;
  flex-shrink: 0;
  transition: background 0.12s ease, border-color 0.12s ease, transform 0.12s ease;
}
.global-mute-btn:hover {
  background: var(--surface-3, var(--surface-2));
  border-color: var(--accent, var(--line));
}
.global-mute-btn:active { transform: scale(0.96); }
.global-mute-btn:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}
.global-mute-icon {
  display: block;
  width: 18px;
  height: 18px;
  background-color: currentColor;
  /* Speaker + waves — unmuted state. Inline SVG path via mask so the icon
   * inherits text color and works under both light and dark themes. */
  -webkit-mask: var(--icon-speaker-on) center / contain no-repeat;
          mask: var(--icon-speaker-on) center / contain no-repeat;
}
.global-mute-btn[aria-pressed="true"] .global-mute-icon {
  -webkit-mask: var(--icon-speaker-off) center / contain no-repeat;
          mask: var(--icon-speaker-off) center / contain no-repeat;
}
.global-mute-btn[aria-pressed="true"] {
  /* Subtle visual cue that audio is muted — dimmed surface + muted text. */
  background: var(--surface-1, var(--surface-2));
  color: var(--muted);
}

/* BACKLOG L291 — audio-state overlays. The chrome mute pill carries a
 * data-audio-state attribute reflecting GlobalAudio.snapshot().loading /
 * .pendingPlay. We render a ::after layer for each state so the existing
 * speaker glyph stays visible underneath — the user can still see what the
 * button does (mute/unmute) and the overlay just communicates the
 * transient engine state. CSS-only; the JS only flips the attribute.
 *
 * - loading: a thin spinning ring around the pill while audio is fetching.
 * - pending: a slow pulsing accent ring while autoplay is blocked, paired
 *   with the native `title` tooltip set in app.js ("Tap to start audio").
 *
 * Both rings sit inside the pill's circular border so they don't shift
 * adjacent appbar layout.
 */
.global-mute-btn[data-audio-state="loading"]::after,
.global-mute-btn[data-audio-state="pending"]::after {
  content: "";
  position: absolute;
  inset: -2px;
  border-radius: 50%;
  pointer-events: none;
}
.global-mute-btn {
  /* Anchor for the ::after overlays — the button was previously a plain
   * inline-flex with no positioning context, so the absolutely-positioned
   * ring would escape to the nearest ancestor (the appbar). */
  position: relative;
}
.global-mute-btn[data-audio-state="loading"]::after {
  border: 2px solid transparent;
  border-top-color: var(--accent, currentColor);
  border-right-color: var(--accent, currentColor);
  animation: pjsk-mute-spin 0.85s linear infinite;
}
.global-mute-btn[data-audio-state="pending"]::after {
  border: 2px solid var(--accent, currentColor);
  animation: pjsk-mute-pulse 1.4s ease-in-out infinite;
}
@keyframes pjsk-mute-spin {
  to { transform: rotate(360deg); }
}
@keyframes pjsk-mute-pulse {
  0%, 100% { opacity: 0.25; transform: scale(1.00); }
  50%      { opacity: 0.85; transform: scale(1.06); }
}
@media (prefers-reduced-motion: reduce) {
  /* Honor the user's motion preference — keep the visual indicator but
   * stop the animation. The static ring still differentiates the state. */
  .global-mute-btn[data-audio-state="loading"]::after,
  .global-mute-btn[data-audio-state="pending"]::after {
    animation: none;
  }
}
/* Speaker icon assets. Inline SVG data URLs so they're zero-network.
 * Paths are the canonical Material Symbols Outlined volume_up / volume_off
 * glyphs (note the Material Symbols viewBox "0 -960 960 960") — verbatim
 * from fonts.gstatic.com so they match the visual weight of the nav-strip
 * icons (also Material Symbols Outlined) exactly. */
:root {
  --icon-speaker-on:  url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 960'><path fill='black' d='M560-131v-82q90-26 145-100t55-168q0-94-55-168T560-749v-82q124 28 202 125.5T840-481q0 127-78 224.5T560-131ZM120-360v-240h160l200-200v640L280-360H120Zm440 40v-322q47 22 73.5 66t26.5 96q0 51-26.5 94.5T560-320ZM400-606l-86 86H200v80h114l86 86v-252ZM300-480Z'/></svg>");
  --icon-speaker-off: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 -960 960 960'><path fill='black' d='M792-56 671-177q-25 16-53 27.5T560-131v-82q14-5 27.5-10t25.5-12L480-368v208L280-360H120v-240h128L56-792l56-56 736 736-56 56Zm-8-232-58-58q17-31 25.5-65t8.5-70q0-94-55-168T560-749v-82q124 28 202 125.5T840-481q0 53-14.5 102T784-288ZM650-422l-90-90v-130q47 22 73.5 66t26.5 96q0 15-2.5 29.5T650-422ZM480-592 376-696l104-104v208Zm-80 238v-94l-72-72H200v80h114l86 86Zm-36-130Z'/></svg>");
}

/* ---------- bottom nav strip (C11) ----------
 * In-game-style: brand chip on the left, route pills in the middle, region
 * select + small attribution on the right. On narrow viewports the strip
 * stays horizontal but the labels shrink and the attribution hides.
 *
 * The flex `order` properties reorder the children visually without moving
 * them in the DOM (so screen readers / tests still see the legacy order:
 * appbar → nav → main). #appbar stays first, #app comes second, #nav last
 * — yielding the top · main · bottom-nav stack we want. */
#appbar { order: 0; }
#app    { order: 1; }
#nav    { order: 2; }
#nav {
  background: var(--surface);
  border-top: 1px solid var(--line);
  padding: 10px 24px calc(10px + env(safe-area-inset-bottom, 0px));
  position: sticky;
  bottom: 0;
  display: flex;
  align-items: center;
  gap: 14px;
  box-shadow: 0 -2px 18px rgba(34, 44, 90, 0.05);
  z-index: 10;
  overflow-x: auto;
  scrollbar-width: thin;
}
/* C18: the nav-brand chip (.brand .brand-mark + .brand-text + .brand-sub)
 * was removed from the markup so the bottom nav strip is just route pills,
 * region, and the attribution footer. The build-version badge now lives
 * inside #nav footer (see #build-version rules in the footer block below). */

/* #build-version is the deploy-time SHA + UTC stamp. It lives inside
 * <footer> in the bottom nav strip. We tone the typography down to a
 * tabular-numeric line so the long "abcdef0 · 2026-05-28 12:00 UTC" string
 * doesn't shout, and lock it to the muted color so it reads as metadata. */
#build-version {
  display: inline-block;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0;
  font-weight: 500;
}

#nav nav {
  display: flex;
  flex-direction: row;
  gap: 4px;
  flex: 1 1 auto;
  justify-content: center;
  min-width: 0;
}
#nav nav a {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
  color: var(--ink-soft);
  padding: 6px 12px;
  border-radius: 12px;
  font-weight: 700;
  font-size: 11px;
  letter-spacing: 0.02em;
  transition: background .12s ease, color .12s ease, transform .12s ease;
  flex-shrink: 0;
  min-width: 64px;
}
#nav nav a:hover { background: var(--surface-2); color: var(--ink); }
#nav nav a.active {
  background: linear-gradient(180deg, rgba(255,95,177,0.16), rgba(51,209,230,0.10));
  color: var(--ink);
  box-shadow: inset 0 0 0 1px rgba(255,95,177,0.18);
}
/* C19: .nico is now a thin wrapper around an inline SVG. The icon paints
 * with currentColor so it inherits the pill's text color (muted by default,
 * brighter on hover, accent on .active). No background chip — cleaner.
 * Width/height define the icon footprint; the inner <svg> grows to fill. */
#nav nav a .nico {
  width: 22px; height: 22px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  color: var(--muted);
  transition: color .12s ease;
}
#nav nav a .nico svg { width: 100%; height: 100%; display: block; }
#nav nav a:hover .nico { color: var(--ink); }
#nav nav a.active .nico { color: var(--accent); }
@media (max-width: 560px) {
  /* Very narrow viewports: drop the label and keep just the icon so all
   * the route pills fit without horizontal scroll. */
  #nav nav a { padding: 6px 8px; min-width: 0; font-size: 0; gap: 0; }
  #nav nav a .nico { width: 24px; height: 24px; }
}

.region {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 10px;
  background: var(--surface-2);
  border-radius: var(--radius-pill);
  border: 1px solid var(--line);
  flex-shrink: 0;
}
.region label {
  font-size: 9px; color: var(--muted);
  text-transform: uppercase; letter-spacing: 0.14em;
  font-weight: 700;
  /* Nudge the "REGION" label 12px in from the pill's left edge so it sits
   * with proper breathing room rather than hugging the border. Combined
   * with the pill's own 10px padding, this puts the label ~22px from the
   * outer pill edge. */
  margin: 0 0 0 12px;
}
.region select {
  background: transparent;
  color: var(--ink);
  border: 0;
  /* 6px top/bottom · 12px left for breathing room from the label; the
   * chevron clearance on the right is overridden just below so the
   * 8×5 chevron icon never crowds the visible text. */
  padding: 6px 12px;
  padding-right: 18px;
  font-size: 12px; font-weight: 700;
  font-family: var(--font-ui);
  cursor: pointer;
  appearance: none;
  -webkit-appearance: none;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 5'><polygon points='0,0 8,0 4,5' fill='%23667' /></svg>");
  background-repeat: no-repeat;
  background-position: right 6px center;
  background-size: 8px 5px;
}
.region select:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-radius: 4px; }
/* Dropdown panel options.
 *
 * Without these rules, `<option>` elements inherit `color: var(--ink)` from
 * the parent `<select>` while the native dropdown panel falls back to the
 * browser's default white background — in dark mode that's pale text on
 * white, completely illegible. We explicitly bind both surface + ink colors
 * so the panel matches the rest of the app in both themes.
 *
 * Chromium and Firefox honor background/color on <option>. Safari is more
 * limited but its native dropdown still inherits color-scheme: dark from
 * the document meta and remains legible. */
.region select option {
  background: var(--surface);
  color: var(--ink);
}
.region select option:checked,
.region select option:hover {
  /* Highlight the active / hovered entry using the lifted surface token so
   * it stays consistent with hovered list rows elsewhere in the app. */
  background: var(--surface-2);
  color: var(--ink);
}

#nav footer {
  color: var(--muted);
  font-size: 10px;
  font-weight: 500;
  flex-shrink: 0;
  max-width: 240px;
  line-height: 1.35;
}
#nav footer a { color: var(--ink-soft); }
@media (max-width: 980px) {
  /* C18: footer used to be hidden at <980px to keep the strip tidy, but it
   * now carries the build-version stamp — a piece of info the user
   * specifically wants visible. Keep it visible at all widths; the strip
   * remains horizontally scrollable when content exceeds the viewport. */
  #nav footer { font-size: 9px; max-width: 180px; }
}

/* ---------- main ---------- */
#app {
  /* C11: main now fills the page width between the top app-bar and the
   * bottom nav strip. Center the content with the page-wide max-width and
   * leave breathing room below so the sticky bottom nav doesn't sit on top
   * of any final paragraph.
   * C23: reverted from C21's 88px back to 24px now that #appbar is sticky
   * again (the bar claims its own row above #app instead of floating). */
  padding: 24px 36px 36px;
  max-width: 1320px;
  width: 100%;
  margin: 0 auto;
  position: relative;
  z-index: 1;
  flex: 1 1 auto;
}
@media (max-width: 720px) {
  /* Narrow viewports: tighter outer padding so cards reach close to the edge.
   * C23: top reverted from 82px (C21 float) back to 18px (sticky again). */
  #app { padding: 18px 18px 24px; }
}
#view.loading::after {
  content: ""; display: block;
  width: 36px; height: 36px;
  border: 3px solid var(--line);
  border-top-color: var(--accent);
  border-radius: 50%;
  animation: spin .9s linear infinite;
  margin: 100px auto;
}
@keyframes spin { to { transform: rotate(360deg); } }

h1, h2, h3 {
  font-family: var(--font-display);
  color: var(--ink);
  letter-spacing: -0.01em;
}
h1 { margin: 0 0 6px; font-size: 30px; font-weight: 900; }
h2 { margin: 30px 0 14px; font-size: 20px; font-weight: 800; }
h2 .accent-bar {
  display: inline-block;
  width: 6px; height: 22px; border-radius: 3px;
  background: linear-gradient(180deg, var(--accent), var(--accent-2));
  vertical-align: -4px;
  margin-right: 10px;
}
h3 { margin: 0 0 4px; font-size: 15px; font-weight: 800; }
p.lead { color: var(--ink-soft); margin: 0 0 22px; max-width: 760px; font-weight: 500; }

/* ---------- hero ---------- */
.hero {
  position: relative;
  background: var(--hero-grad);
  border-radius: var(--radius-lg);
  padding: 36px 36px 32px;
  margin-bottom: 26px;
  box-shadow: var(--shadow-md);
  overflow: hidden;
}
.hero::before {
  content: ""; position: absolute; inset: 0;
  background:
    radial-gradient(500px 280px at 90% 10%, rgba(51,209,230,0.18), transparent 60%),
    radial-gradient(420px 280px at 10% 110%, rgba(255,95,177,0.20), transparent 60%);
  pointer-events: none;
}
.hero::after {
  content: ""; position: absolute; top: -10px; right: -10px;
  width: 200px; height: 200px;
  background:
    url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'><polygon points='30,30 60,80 0,80' fill='%23ff5fb1' opacity='0.18'/><polygon points='130,20 170,90 90,90' fill='%2333d1e6' opacity='0.18'/><polygon points='60,120 90,170 30,170' fill='%2388dd44' opacity='0.18'/><polygon points='140,130 180,180 100,180' fill='%23ffd166' opacity='0.18'/></svg>") center/contain no-repeat;
  pointer-events: none;
}
.hero h1 {
  position: relative;
  font-size: 34px;
  background: linear-gradient(95deg, var(--accent), var(--accent-2) 80%);
  -webkit-background-clip: text; background-clip: text; color: transparent;
}
.hero p { position: relative; color: var(--ink-soft); margin: 10px 0 0; max-width: 720px; font-weight: 500; }

/* ---------- grid + cards ---------- */
.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
  gap: 16px;
}
.card {
  position: relative;
  background: var(--surface);
  border-radius: var(--radius);
  padding: 16px 16px 14px;
  box-shadow: var(--shadow-sm);
  border: 1px solid var(--line);
  overflow: hidden;
  cursor: pointer;
  transition: transform .14s ease, box-shadow .14s ease, border-color .14s ease;
  display: block;
  color: var(--ink);
}
.card:hover {
  transform: translateY(-3px);
  box-shadow: var(--shadow-md);
  border-color: var(--line-strong);
  color: var(--ink);
}
.card h3 { font-size: 15px; font-weight: 800; color: var(--ink); margin: 6px 0 4px; line-height: 1.3; }
.card .tag {
  display: inline-flex; align-items: center; gap: 6px;
  font-size: 11px; padding: 3px 10px;
  border-radius: var(--radius-pill);
  background: linear-gradient(135deg, var(--accent), var(--accent-2));
  color: white;
  font-weight: 800;
  letter-spacing: 0.04em;
  margin-bottom: 4px;
  text-transform: uppercase;
  box-shadow: 0 2px 6px rgba(255,95,177,0.30);
}
.card .meta {
  color: var(--ink-soft);
  font-size: 12px;
  margin-top: 6px;
  font-weight: 500;
}
.card.unit-stripe::before {
  content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 5px;
  background: var(--stripe, var(--u-none));
}
.card.unit-stripe {
  padding-left: 20px;
}

/* unit color helpers */
.u-light_sound { --stripe: var(--u-light_sound); }
.u-idol { --stripe: var(--u-idol); }
.u-street { --stripe: var(--u-street); }
.u-theme_park { --stripe: var(--u-theme_park); }
.u-school_refusal { --stripe: var(--u-school_refusal); }
.u-piapro { --stripe: var(--u-piapro); }
.u-none { --stripe: var(--u-none); }

/* unit "seal" — like the in-game round chip */
.unit-seal {
  display: inline-flex; align-items: center; gap: 8px;
  background: var(--surface);
  border-radius: var(--radius-pill);
  padding: 4px 14px 4px 4px;
  box-shadow: var(--shadow-sm);
  font-size: 13px; font-weight: 800;
  color: var(--ink);
}
.unit-seal .dot {
  width: 26px; height: 26px;
  border-radius: 50%;
  background: var(--stripe);
  box-shadow: inset 0 -2px 0 rgba(0,0,0,0.12);
  display: grid; place-items: center;
  color: white; font-size: 12px; font-weight: 900;
}

/* breadcrumbs */
.crumbs { color: var(--ink-soft); margin-bottom: 18px; font-size: 13px; font-weight: 600; }
.crumbs a { color: var(--ink-soft); }
.crumbs a:hover { color: var(--accent); }

/* ---------- inputs / search ---------- */
.search-row { display: flex; gap: 10px; margin: 8px 0 22px; flex-wrap: wrap; align-items: center; }
.search-row input, .search-row select {
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  padding: 10px 18px;
  font-size: 14px; font-weight: 600;
  font-family: var(--font-ui);
  min-width: 180px;
  box-shadow: var(--shadow-sm);
  transition: border-color .14s ease, box-shadow .14s ease;
}
.search-row input:focus, .search-row select:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 4px rgba(255,95,177,0.18);
}
.search-row input::placeholder { color: var(--muted); }

/* ---------- empty / messages ---------- */
.empty {
  color: var(--ink-soft);
  padding: 50px;
  text-align: center;
  background: var(--surface);
  border: 2px dashed var(--line-strong);
  border-radius: var(--radius-lg);
  font-weight: 600;
}

/* ---------- buttons ---------- */
.btn {
  background: var(--surface);
  color: var(--ink);
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  padding: 10px 20px;
  font-size: 14px;
  font-weight: 800;
  font-family: var(--font-ui);
  cursor: pointer;
  box-shadow: var(--shadow-sm);
  transition: transform .1s ease, box-shadow .14s ease, border-color .14s ease;
  display: inline-flex; align-items: center; gap: 8px;
}
.btn:hover { transform: translateY(-1px); box-shadow: var(--shadow-md); border-color: var(--line-strong); }
.btn:active { transform: translateY(0); }
.btn.primary {
  background: linear-gradient(135deg, var(--accent), var(--accent-2));
  color: white;
  border-color: transparent;
  box-shadow: 0 6px 14px rgba(255,95,177,0.30);
}
.btn.primary:hover { box-shadow: 0 10px 22px rgba(255,95,177,0.40); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }

/* The "big action" pill button used on the home for the four hero CTAs,
   inspired by the in-game bottom action bar (Gacha/Characters/Story/Show). */
.action-pills {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
  gap: 14px;
  margin: 0 0 28px;
}
.action-pill {
  background: var(--surface);
  border-radius: var(--radius-pill);
  padding: 10px 18px 10px 10px;
  display: flex; align-items: center; gap: 12px;
  box-shadow: var(--shadow-md);
  border: 1px solid var(--line);
  cursor: pointer;
  transition: transform .12s ease, box-shadow .14s ease;
  color: var(--ink);
  text-decoration: none;
}
.action-pill:hover { transform: translateY(-3px); box-shadow: var(--shadow-lg); color: var(--ink); }
.action-pill .ap-ico {
  width: 44px; height: 44px;
  border-radius: 14px;
  display: grid; place-items: center;
  font-size: 20px;
  color: white;
  background: linear-gradient(135deg, var(--ap-c1, var(--accent)), var(--ap-c2, var(--accent-2)));
  flex-shrink: 0;
  box-shadow: 0 4px 10px rgba(34,44,90,0.18);
}
.action-pill .ap-text { line-height: 1.15; }
.action-pill .ap-title { font-weight: 800; font-size: 15px; color: var(--ink); }
.action-pill .ap-sub { font-size: 12px; color: var(--ink-soft); margin-top: 2px; font-weight: 500; }

.action-pill.c-stories  { --ap-c1: #ff7eb6; --ap-c2: #ff5fb1; }
.action-pill.c-events   { --ap-c1: #ffb74d; --ap-c2: #ff9900; }
.action-pill.c-chars    { --ap-c1: #88dd44; --ap-c2: #4caf50; }
.action-pill.c-music    { --ap-c1: #33d1e6; --ap-c2: #4455dd; }
.action-pill.c-assets   { --ap-c1: #b388e0; --ap-c2: #884cc2; }

/* ---------- character grid ---------- */
.char-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 14px;
}
.char-card {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  padding: 18px 14px 14px;
  text-align: center;
  box-shadow: var(--shadow-sm);
  transition: transform .14s ease, box-shadow .14s ease;
  position: relative;
  overflow: hidden;
}
.char-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); }
.char-card::before {
  content: ""; position: absolute; top: 0; left: 0; right: 0; height: 60px;
  background: linear-gradient(180deg, var(--stripe, var(--u-none)) 0%, transparent 100%);
  opacity: 0.18;
  pointer-events: none;
}
.char-avatar {
  width: 84px; height: 84px;
  margin: 4px auto 12px;
  border-radius: 50%;
  background: var(--stripe, #cfd6ef);
  display: flex; align-items: center; justify-content: center;
  font-size: 32px;
  font-family: var(--font-display);
  font-weight: 900;
  color: white;
  box-shadow:
    inset 0 -4px 0 rgba(0,0,0,0.12),
    0 6px 16px rgba(34,44,90,0.16);
  border: 3px solid white;
  position: relative;
  z-index: 1;
}
.char-name { font-weight: 800; font-size: 14px; color: var(--ink); }
.char-name-en {
  color: var(--muted);
  font-size: 11px;
  letter-spacing: 0.08em;
  font-weight: 700;
  margin-top: 2px;
  text-transform: uppercase;
}
.char-unit-label {
  color: var(--ink-soft);
  font-size: 11px;
  margin-top: 8px;
  font-weight: 700;
  padding: 4px 10px;
  background: var(--surface-2);
  border-radius: var(--radius-pill);
  display: inline-block;
}

/* ---------- reader ---------- */
.reader {
  display: grid; grid-template-columns: 1fr; gap: 18px;
  max-width: 940px; margin: 0 auto;
}
.reader-stage {
  position: relative;
  background: var(--surface);
  border-radius: var(--radius-lg);
  padding: 0;
  overflow: hidden;
  aspect-ratio: 16/9;
  background-image: var(--bg-img, none);
  background-size: cover;
  background-position: center;
  display: flex; align-items: flex-end;
  box-shadow: var(--shadow-md);
  border: 1px solid var(--line);
  /* Stage is click-to-advance / click-to-skip-reveal (Phase 12). The stage
     click handler explicitly bails when the event target is inside the
     dialogue text (.speaker / .line / .jp-popup / [data-jp-lookable]) so
     selecting text, hovering JP tokens, and clicking popup buttons never
     advance the player. See reader/index.js stage.addEventListener('click'). */
  cursor: pointer;
  /* Suppress the mobile browser default tap-highlight (a translucent blue
     wash Chrome/Android paints over the tap target before the click fires).
     In fullscreen this reads as a brief blue tint over the whole viewport
     just before the dialogue advances. We have our own ring + confetti
     feedback; the OS overlay is redundant and out-of-style. */
  -webkit-tap-highlight-color: transparent;
}
/* Same suppression for the app-wide tap-fx-layer when it's reparented
   into #stage on fullscreen — it inherits hit-testing from the stage,
   and some browsers paint the highlight on the layer itself. Belt and
   braces. */
.tap-fx-layer,
.reader-stage * {
  -webkit-tap-highlight-color: transparent;
}
/* Subtle bottom vignette so white speaker text always has a darker substrate
   behind it, no matter what the scene background looks like. This is gentler
   than the previous white wash and matches the in-game story player. */
.reader-stage::before {
  content: ""; position: absolute; inset: 0;
  background:
    linear-gradient(180deg, transparent 0%, transparent 55%, rgba(0,0,0,0.40) 100%);
  pointer-events: none;
  z-index: 2;
}
/* If no background image we want a soft fallback gradient */
.reader-stage:not([style*="--bg-img"])::after {
  content: ""; position: absolute; inset: 0;
  background:
    linear-gradient(135deg, #d8efff 0%, #ffe0f0 100%);
  pointer-events: none;
}
/* Default stacking for direct children. Specific overlays (dialogue, scene
   banner) opt out by setting position:absolute themselves — see those rules. */
.reader-stage > *:not(.dialogue-overlay):not(.scene-banner):not(.stage-portrait):not(.stage-live2d):not(.info-pills):not(.stage-bg):not(.stage-fullcolor):not(.stage-flashback):not(.stage-blackwipe):not(.stage-scene-effect):not(.stage-fulltext):not(.stage-fs-btn):not(.stage-settings-btn):not(.stage-settings-panel):not(.jp-popup):not(.tap-fx-layer) {
  position: relative; z-index: 2;
}
/* .tap-fx-layer is reparented into #stage when fullscreen activates so the
   tap effect renders above the FS host. The allowlist above used to capture
   it and apply position:relative; z-index:2, which collapsed the layer to
   0×0 and reset its inset:0 — making child .tap-fx absolutes position
   relative to a stub at its natural-flow corner, so ring scales rendered
   as a teal viewport tint ("blue tint, no rings" bug). The :not exemption
   above + the rule at line 2321 below keep position:fixed; inset:0 active. */

/* Background cross-fade layers (Phase 9). Two stacked absolute divs that
   ping-pong as the active BG changes — the incoming one fades to opacity:1
   while the outgoing one fades to 0. The 300ms timing is roughly halfway
   between sekai-viewer's 200ms layer fade and the perceived feel of a
   scene transition in-game. */
.reader-stage .stage-bg {
  position: absolute;
  inset: 0;
  background-size: cover;
  background-position: center;
  opacity: 0;
  transition: opacity 300ms ease;
  z-index: 1;
  pointer-events: none;
}
.reader-stage .stage-bg.active { opacity: 1; }

/* While the BG is swapping, dim the dialogue overlay so text doesn't fight
   the transition. Mirrors sekai-viewer's dialog.hide(200) -> draw bg -> show. */
.dialogue-overlay.fading { opacity: 0; transition: opacity 200ms ease; }

/* Dialogue-hidden state (Commit E of mobile touch overhaul).
   When the reader has `.dialogue-hidden`, the dialogue bubble + speaker +
   any JP-lookup popups fade out and stop intercepting pointer events so
   the user sees the artwork unobstructed. A subsequent tap or swipe-up
   removes the class (handled in JS) and the overlay fades back in.

   We intentionally do NOT hide .scene-banner or .info-pills here — those
   are scene metadata, not dialogue. The user can still see which scene
   they're in while the dialogue is dismissed. */
.reader.dialogue-hidden .dialogue-overlay,
.reader.dialogue-hidden .jp-popup {
  opacity: 0;
  pointer-events: none;
  transition: opacity 220ms ease;
}
@media (prefers-reduced-motion: reduce) {
  .reader.dialogue-hidden .dialogue-overlay,
  .reader.dialogue-hidden .jp-popup {
    transition: none;
  }
}

/* Speaker portrait — lightweight "character on stage" using member_cutout.
   Full Live2D rendering would require shipping the Cubism SDK (~1MB JS)
   plus loading .moc3 models from sekai-live2d-assets, so we use this
   static-but-real game-asset approach instead. */
.reader-stage .stage-portrait {
  position: absolute;
  right: 4%;
  bottom: 0;
  max-height: 92%;
  max-width: 48%;
  object-fit: contain;
  object-position: bottom right;
  z-index: 2;
  filter: drop-shadow(0 8px 24px rgba(34,44,90,0.18));
  transition: opacity 0.25s ease, transform 0.35s ease;
  opacity: 1;
  pointer-events: none;
}
.reader-stage .stage-portrait.hidden { opacity: 0; }

/* Live2D canvas overlay — lives in the same stacking slot as the cutout but
   spans the full stage so the model's bottom-anchor positioning matches the
   in-game framing (feet near the bottom edge, head 95% up). The canvas is
   `position: absolute` so it doesn't claim layout space when hidden. */
.reader-stage .stage-live2d {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 2;
  pointer-events: none;
  /* Slight drop shadow under the character to anchor them visually on the
     scene background, similar to the cutout's filter. */
  filter: drop-shadow(0 8px 24px rgba(34,44,90,0.22));
}
.reader-stage .stage-live2d[hidden] { display: none; }

.reader-stage .info-pills {
  position: absolute; top: 16px; left: 16px; right: 16px;
  display: flex; gap: 8px; flex-wrap: wrap; z-index: 4;
}
.reader-stage .info-pills[hidden] { display: none; }
.pill {
  background: rgba(255,255,255,0.92);
  backdrop-filter: blur(8px);
  border: 1px solid rgba(255,255,255,0.6);
  border-radius: var(--radius-pill);
  padding: 5px 12px;
  font-size: 12px;
  color: var(--ink-soft);
  font-weight: 700;
  box-shadow: var(--shadow-sm);
}
.pill strong { color: var(--ink); font-weight: 800; margin-left: 4px; }

/* In-game style dialogue overlay (Phase 8). No panel, no border — just white
   text with a strong black stroke so it stays legible on any background.
   Speaker name sits above the line with a thin gradient underline. */
.dialogue-overlay {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  padding: 0 7% 5%;
  z-index: 3;
  /* Soft dark scrim behind the dialogue area so white text stays legible
     on bright scene backgrounds without requiring heavy per-glyph shadows.
     Starts fully transparent at the top, ramps to 33% black by 50px, then
     holds that level to the bottom edge. */
  background: linear-gradient(
    to bottom,
    rgba(0, 0, 0, 0) 0,
    rgba(0, 0, 0, 0.33) 50px,
    rgba(0, 0, 0, 0.33) 100%
  );
  /* Overlay itself ignores pointer events so it doesn't block stage clicks,
     but individual text nodes opt back in (see .speaker / .line below) so
     the dialogue is selectable + hoverable for future enhancements. */
  pointer-events: none;
  /* Per-step text stroke so the in-game look survives over any background. */
  --txt-stroke: 1.5px;
}
.reader.has-secondary .dialogue-overlay {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 28px;
}
/* `[hidden]` must beat the .has-secondary display:grid rule above so effect-step
   scene banners don't show stale dialogue text underneath them. !important is
   the simplest cross-browser fix — the `hidden` attribute should always win. */
.dialogue-overlay[hidden] { display: none !important; }
@media (max-width: 720px) {
  .reader.has-secondary .dialogue-overlay {
    grid-template-columns: 1fr;
    gap: 6px;
  }
  .dialogue-overlay { padding: 0 5% 4%; }
}

/* Speaker name + its sibling gradient underline. Splitting them lets the
   underline sit at a fixed y regardless of font metrics, and lets us shift
   the name independently to overlap the underline like the in-game label. */
.speaker-row {
  position: relative;
  display: inline-block;        /* shrink-wraps to the speaker text */
  padding-bottom: 6px;          /* reserves room for the underline track */
}
.speaker {
  /* Roboto 900 for Latin, Zen Kaku Gothic New 900 for JP. The softer Roboto
     paired with a wide, semi-transparent black halo (no drop shadow) gives
     the in-game label that fuzzy outline rather than a hard stroke. */
  font-family: "Roboto", "Zen Kaku Gothic New", "Hiragino Kaku Gothic ProN", "Yu Gothic", var(--font-display);
  font-weight: 900;
  font-size: clamp(15px, 1.7vw, 21px);
  color: #fff;
  display: block;
  padding-right: 26px;          /* reserve right-side space for the fade */
  /* Fat translucent black halo around each glyph — reads as a soft outline
     against scene art without the brittle look of a hard 1.4px stroke. */
  -webkit-text-stroke: 4px rgba(0, 0, 0, 0.3);
  paint-order: stroke fill;
  letter-spacing: 0.02em;
  position: relative;
  z-index: 1;
  /* Pull the name down + slightly left so the bottom of the glyphs lands
     on the sibling underline and the halo extends past the gradient's
     left edge (matches the in-game label overlap). */
  margin-bottom: -4px;
  margin-left: -10px;
  transform: translateY(4px);
  /* Selectable + hoverable for future enhancements. */
  pointer-events: auto;
  user-select: text;
}
.speaker[hidden] { display: none; }
/* If both the name and its underline are hidden, collapse the row entirely so
   narrator/unknown lines float at the bottom with no reserved label height. */
.speaker-row:has(> .speaker[hidden]) { display: none; }

/* Sibling gradient underline. Extends past the speaker-row's content box
   (left:-30, right:-200) so the streak runs in from the left margin and
   trails further to the right than the name itself. */
.speaker-underline {
  position: absolute;
  left: -20px; right: -200px; bottom: 0;
  height: 8px;
  border-radius: 4px;
  background: linear-gradient(90deg,
    rgba(255,255,255,0.75) 0%,
    rgba(255,255,255,0.5) 35%,
    rgba(255,255,255,0) 100%);
  pointer-events: none;
  z-index: 0;
}
.speaker-underline[hidden] { display: none; }

.line {
  /* Phase 9: ~2/3 of previous size so more text fits on one line. The
     speaker label keeps its original 15-21px range so name/line read with
     a clear size contrast (matches in-game proportions).

     Reader font-size slider: --reader-line-scale (set on .reader by
     PREF.fontSize, default 1) multiplies the clamped base size. The
     clamp() still bounds the responsive sizing; the multiplier shifts
     the whole curve up or down within its declared rails.

     2026-05-29: switched the responsive term from 1.2vw (~tight,
     5px swing 11→16px across all real devices) to 3vmin. vmin is
     the smaller of viewport width/height, so it stays symmetric
     across landscape↔portrait and reacts intuitively in fullscreen
     mode where the short dimension dominates readability. The 13→22px
     clamp gives the slider a meaningful base to multiply against on
     every device, which lets the mobile default drop from 1.75× back
     to 1.00× (handled in reader/state.js). Slider range was
     simultaneously narrowed 0.5–2.5 → 0.5–2.0; stored values above
     2.0 are re-clamped on next read. */
  font-size: calc(clamp(13px, 3vmin, 22px) * var(--reader-line-scale, 1));
  line-height: 1.55;
  /* Phase 10: reserve at least three lines of vertical space so the speaker
     name's y-position doesn't shift between short and long dialogue. */
  min-height: calc(1.55em * 3);
  margin-top: 8px;        /* breathing room below the thicker underline */
  color: #fff;
  font-weight: 600;
  white-space: pre-wrap;
  -webkit-text-stroke: 1.1px #000;
  paint-order: stroke fill;
  text-shadow: 0 2px 8px rgba(0,0,0,0.6);
  letter-spacing: 0.01em;
  /* Selectable + hoverable for future enhancements (mouseover/touch). */
  pointer-events: auto;
  user-select: text;
  cursor: text;
}

/* ---- Scene banner (Phase 8): dark pill centered on stage, used for the
   'place / time-of-day' effect steps. The horizontal gradient gives it a
   fade on both ends, matching the in-game stinger banner. */
.scene-banner {
  position: absolute;
  left: 0; right: 0; top: 50%;
  transform: translateY(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 4px;
  z-index: 3;
  pointer-events: none;
  /* Background lives on the banner row itself so the fade can extend wider
     than the text. */
  background:
    linear-gradient(90deg, rgba(33,38,52,0) 0%, rgba(33,38,52,0.78) 22%, rgba(33,38,52,0.85) 50%, rgba(33,38,52,0.78) 78%, rgba(33,38,52,0) 100%);
  padding: 18px 4%;
}
.scene-banner[hidden] { display: none; }
.scene-banner-text {
  color: #fff;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(16px, 2.1vw, 26px);
  letter-spacing: 0.04em;
  text-shadow: 0 2px 8px rgba(0,0,0,0.6);
}
/* Phase 13: secondary-language ruby line beneath the primary banner text.
   Smaller, slightly translucent, sits directly under the primary line. */
.scene-banner-text-secondary {
  color: rgba(255,255,255,0.82);
  font-family: var(--font-display);
  font-weight: 500;
  font-size: clamp(11px, 1.35vw, 16px);
  letter-spacing: 0.02em;
  line-height: 1.25;
  text-shadow: 0 1px 4px rgba(0,0,0,0.55);
  max-width: 80%;
  text-align: center;
}
.scene-banner-text-secondary[hidden] { display: none; }

/* ---- Phase 13 stage overlay layers ---------------------------------
   All four sit absolutely over the stage. They start hidden and are
   driven by inline styles (opacity / clip-path / transform) set by the
   effect helpers in reader.js. */
.stage-fullcolor,
.stage-flashback,
.stage-blackwipe,
.stage-fulltext {
  position: absolute;
  inset: 0;
  pointer-events: none;
}
/* Full-screen color (BlackIn/Out, WhiteIn/Out) — z above bg + chars + bubble,
   below the controls/header so we don't block menu interactions. */
.stage-fullcolor {
  background: #000;
  opacity: 0;
  z-index: 9;
}
.stage-fullcolor[hidden] { display: none; }

/* Flashback tint — lighter than fullcolor (30% black), sits under it. */
.stage-flashback {
  background: rgba(0,0,0,0.30);
  opacity: 0;
  z-index: 4;
}
.stage-flashback[hidden] { display: none; }

/* BlackWipe — solid black layer animated by clip-path. Sits at the same
   stack level as fullcolor so it covers everything but UI chrome. */
.stage-blackwipe {
  background: #000;
  clip-path: inset(0 0 0 100%);
  z-index: 10;
}
.stage-blackwipe[hidden] { display: none; }

/* Scene-effect overlay — shared layer for PlayScenarioEffect / StopScenarioEffect
   (ET15 / ET16). For the black_out family this carries a solid black tint at
   a variant-driven opacity (0.5 – 0.8). Future commits will reuse this same
   element for SVG / particle content (line, kirakira, light_up families) by
   appending children; the base rules just guarantee correct positioning and
   stacking. Sits at the same level as fullcolor so subsequent overlays still
   layer cleanly above it. */
.stage-scene-effect {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: transparent;
  opacity: 0;
  transition: opacity 200ms ease-in-out;
  z-index: 9;
}
.stage-scene-effect[hidden] { display: none; }

/* FullScreenText — centered narration card with optional black fade BG.
   Sits above flashback but below blackwipe so blackwipes still cover it. */
.stage-fulltext {
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 6;
}
.stage-fulltext[hidden] { display: none; }
.stage-fulltext-bg {
  position: absolute;
  inset: 0;
  background: rgba(0,0,0,0.85);
  opacity: 0;
  transition: opacity 200ms ease-in-out;
}
.stage-fulltext-bg[hidden] { display: none; }
.stage-fulltext-body {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  padding: 24px 32px;
  text-align: center;
  z-index: 1;
}
.stage-fulltext-line {
  color: #fff;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(20px, 2.6vw, 30px);
  line-height: 1.45;
  letter-spacing: 0.04em;
  text-shadow: 0 2px 12px rgba(0,0,0,0.75);
  max-width: 80vw;
  white-space: pre-wrap;
}
.stage-fulltext-line-secondary {
  color: rgba(255,255,255,0.82);
  font-family: var(--font-display);
  font-weight: 500;
  font-size: clamp(13px, 1.55vw, 18px);
  line-height: 1.4;
  letter-spacing: 0.02em;
  text-shadow: 0 1px 6px rgba(0,0,0,0.7);
  max-width: 78vw;
  white-space: pre-wrap;
}
.stage-fulltext-line-secondary[hidden] { display: none; }

.reader-controls {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
  padding: 0 4px;
}

/* Audio control strip below the main playback row */
.reader-audio {
  display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
  padding: 10px 14px;
  margin: -4px 0 0;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-sm);
  font-size: 13px;
  color: var(--ink-soft);
}
.reader-audio .audio-toggle {
  display: inline-flex; align-items: center; gap: 6px;
  cursor: pointer; user-select: none;
}
.reader-audio .audio-toggle input[type="checkbox"] {
  accent-color: var(--accent);
  width: 16px; height: 16px;
}
.reader-audio .audio-toggle.vol { gap: 10px; flex: 1; min-width: 160px; max-width: 280px; }
.reader-audio .audio-toggle.vol input[type="range"] {
  flex: 1; accent-color: var(--accent);
}
.reader-audio .now-playing {
  margin-left: auto;
  font-family: var(--font-mono, ui-monospace, monospace);
  font-size: 12px;
  color: var(--ink-soft);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}
/* The progress bar is an <input type="range"> so users can drag to jump to
   any line. We strip the OS-default appearance and rebuild the track + thumb
   to match the prior gradient-pill look. Each vendor prefix needs its own
   rule because browsers won't apply shared selectors across them. */
.progress {
  flex: 1; min-width: 140px;
  height: 8px;
  background: var(--line);
  border-radius: 999px;
  box-shadow: inset 0 1px 2px rgba(34,44,90,0.06);
  /* Reset range-input chrome (border, focus ring, font) */
  -webkit-appearance: none;
  appearance: none;
  margin: 0;
  padding: 0;
  outline: none;
  cursor: pointer;
  /* The browser-default thumb extends outside the 8px track on some
     engines; allow it to visually overhang without clipping the gradient. */
  overflow: visible;
}
.progress:focus-visible {
  box-shadow: inset 0 1px 2px rgba(34,44,90,0.06), 0 0 0 3px rgba(99, 102, 241, 0.28);
}
/* WebKit / Blink: track + thumb are separate pseudo-elements. */
.progress::-webkit-slider-runnable-track {
  height: 8px;
  background: linear-gradient(
    to right,
    var(--accent) 0%,
    var(--accent-2) calc((var(--progress-pct, 0)) * 1%),
    transparent calc((var(--progress-pct, 0)) * 1%),
    transparent 100%
  );
  border-radius: 999px;
}
.progress::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 16px; height: 16px;
  margin-top: -4px;          /* center on 8px track */
  border-radius: 50%;
  background: var(--accent);
  border: 2px solid #fff;
  box-shadow: 0 1px 3px rgba(34,44,90,0.25);
}
/* Firefox: --moz pseudo-elements; track + progress are separate primitives. */
.progress::-moz-range-track {
  height: 8px;
  background: transparent;   /* the filled portion paints via -progress */
  border-radius: 999px;
}
.progress::-moz-range-progress {
  height: 8px;
  background: linear-gradient(90deg, var(--accent), var(--accent-2));
  border-radius: 999px;
}
.progress::-moz-range-thumb {
  width: 16px; height: 16px;
  border-radius: 50%;
  background: var(--accent);
  border: 2px solid #fff;
  box-shadow: 0 1px 3px rgba(34,44,90,0.25);
}
.counter {
  color: var(--ink-soft);
  font-size: 13px;
  min-width: 80px;
  text-align: right;
  font-weight: 700;
}

.appear { display: flex; gap: 8px; flex-wrap: wrap; margin: 4px 0 0; padding: 0 4px; }
.appear .chip {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  padding: 5px 14px;
  font-size: 12px;
  color: var(--ink-soft);
  font-weight: 700;
  box-shadow: var(--shadow-sm);
}

.script-trail {
  margin-top: 6px;
  background: var(--surface);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-sm);
  border: 1px solid var(--line);
  padding: 10px;
  max-height: 420px;
  overflow-y: auto;
}
.script-trail .entry {
  padding: 10px 14px;
  border-radius: 12px;
  margin-bottom: 4px;
  cursor: pointer;
  opacity: 0.6;
  transition: background .12s ease, opacity .12s ease;
}
.script-trail .entry:hover { background: var(--surface-2); opacity: 1; }
.script-trail .entry.current {
  opacity: 1;
  background: linear-gradient(90deg, rgba(255,95,177,0.10), rgba(51,209,230,0.06));
}
.script-trail .entry .who {
  color: var(--accent);
  font-size: 12px;
  font-weight: 800;
  margin-bottom: 2px;
}
.script-trail .entry .what {
  font-size: 13px;
  color: var(--ink-soft);
  font-weight: 500;
}
.script-trail .entry.current .what { color: var(--ink); }

/* Hide the secondary entry column by default so single-language layout is
   unaffected. When .has-secondary is on, both columns render side-by-side. */
.script-trail .entry .entry-col.secondary { display: none; }
.reader.has-secondary .script-trail .entry {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 16px;
}
.reader.has-secondary .script-trail .entry .entry-col.secondary {
  display: block;
  border-left: 1px solid rgba(34,44,90,0.08);
  padding-left: 14px;
}
.reader.has-secondary .script-trail .entry .entry-col.secondary .who {
  color: var(--accent-2, #33d1e6);
}
@media (max-width: 720px) {
  .reader.has-secondary .script-trail .entry {
    grid-template-columns: 1fr;
    gap: 6px;
  }
  .reader.has-secondary .script-trail .entry .entry-col.secondary {
    border-left: 0;
    border-top: 1px solid rgba(34,44,90,0.08);
    padding-left: 0;
    padding-top: 6px;
  }
}

/* Secondary-language dropdown in the audio strip */
.reader-audio .secondary-lang select {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 4px 8px;
  font: inherit;
  color: var(--ink);
  cursor: pointer;
  max-width: 200px;
}

/* JP-lookup loading indicator. Shown only while kuromoji + the dictionary
   manifest are warming up and JP is in play (primary=jp or secondary=jp).
   Visually subtle so it reads as ambient status, not a primary control.
   Lives next to both the toolbar (#t-jp-loading) and drawer (#fs-jp-loading)
   secondary-language selects. The reader sets `hidden` to toggle visibility;
   `.jp-lookup-loading-failed` swaps the colour to a soft warning tone when
   the manifest fetch failed (text becomes "(dict unavailable)"). */
.jp-lookup-loading {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  margin-left: 8px;
  padding: 2px 8px;
  border-radius: 999px;
  background: var(--surface, rgba(255,255,255,0.06));
  border: 1px solid var(--line, rgba(255,255,255,0.12));
  font-size: 0.85em;
  color: var(--muted, #9aa0a6);
  white-space: nowrap;
  opacity: 0.9;
  /* `hidden` still wins over inline-flex, but be explicit so a flex parent
     can't accidentally lay out a hidden indicator. */
}
.jp-lookup-loading[hidden] { display: none; }
.jp-lookup-loading.jp-lookup-loading-failed {
  color: var(--warn, #d39e3a);
  border-color: var(--warn, #d39e3a);
}

/* ---------- asset viewer ---------- */
.asset-viewer {
  display: grid;
  grid-template-columns: 320px 1fr;
  gap: 18px;
  align-items: start;
}
.asset-viewer .panel {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  padding: 18px;
  box-shadow: var(--shadow-sm);
}
.asset-viewer .panel label {
  display: block;
  font-size: 11px;
  color: var(--muted);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.12em;
  margin-bottom: 6px;
}
.asset-viewer input {
  width: 100%;
  background: var(--surface-2);
  color: var(--ink);
  border: 1px solid var(--line);
  border-radius: 10px;
  padding: 9px 12px;
  margin-bottom: 12px;
  font-size: 13px;
  font-family: ui-monospace, "SF Mono", monospace;
  font-weight: 500;
}
.asset-viewer input:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3px rgba(255,95,177,0.18);
}
.asset-viewer .preset-list { display: grid; gap: 6px; }
.asset-viewer .preset-list .btn {
  text-align: left;
  justify-content: flex-start;
  font-size: 13px;
  padding: 9px 14px;
  font-weight: 600;
}
.asset-viewer .preview {
  min-height: 460px;
  display: flex; align-items: center; justify-content: center;
  background:
    linear-gradient(45deg, #f0f4ff 25%, transparent 25%),
    linear-gradient(-45deg, #f0f4ff 25%, transparent 25%),
    linear-gradient(45deg, transparent 75%, #f0f4ff 75%),
    linear-gradient(-45deg, transparent 75%, #f0f4ff 75%);
  background-size: 24px 24px;
  background-position: 0 0, 0 12px, 12px -12px, -12px 0;
  background-color: #fbfcff;
  border-radius: var(--radius-lg);
  padding: 28px;
  border: 1px solid var(--line);
}
.asset-viewer .preview img {
  max-height: 60vh;
  border-radius: 12px;
  box-shadow: var(--shadow-lg);
  background: white;
}

/* ---------- song cards ---------- */
.song-card-art {
  aspect-ratio: 1;
  background-color: var(--surface-2);
  background-size: cover;
  background-position: center;
  border-radius: 10px;
  margin-bottom: 12px;
  box-shadow: var(--shadow-sm);
  border: 1px solid var(--line);
}
.card .tag.tag-soft {
  background: var(--surface-2);
  color: var(--ink-soft);
  margin-right: 4px;
  margin-bottom: 4px;
  box-shadow: none;
  border: 1px solid var(--line);
  font-size: 10px;
  padding: 2px 8px;
}

/* ================================================================
   ASSET-DRIVEN COMPONENTS
   ================================================================ */

/* When a CDN image 404s, hide it cleanly so the layout still flows. */
img.img-broken {
  visibility: hidden;
}

/* ---------- Featured banner (home page hero) ---------- */
.featured-banner {
  position: relative;
  display: block;
  border-radius: var(--radius-lg);
  overflow: hidden;
  margin-bottom: 28px;
  aspect-ratio: 21 / 9;
  background: var(--surface-2);
  box-shadow: var(--shadow-lg);
  border: 1px solid var(--line);
  transition: transform .18s ease, box-shadow .18s ease;
  color: white;
  isolation: isolate;
}
.featured-banner:hover { transform: translateY(-2px); box-shadow: 0 18px 40px rgba(20,30,80,0.18); }
.featured-banner-art {
  position: absolute; inset: 0;
  background-size: cover;
  background-position: center;
  z-index: 1;
}
.featured-banner-overlay {
  position: absolute; inset: 0;
  background: linear-gradient(95deg,
    rgba(20, 26, 60, 0.78) 0%,
    rgba(20, 26, 60, 0.55) 36%,
    rgba(20, 26, 60, 0.05) 70%,
    rgba(20, 26, 60, 0)    100%);
  z-index: 2;
}
.featured-banner-content {
  position: absolute;
  left: 36px;
  bottom: 30px;
  z-index: 4;
  max-width: 55%;
}
.featured-banner-eyebrow {
  display: inline-flex; align-items: center; gap: 8px;
  background: rgba(255, 255, 255, 0.16);
  backdrop-filter: blur(8px);
  border: 1px solid rgba(255, 255, 255, 0.28);
  color: white;
  font-weight: 700; font-size: 11px;
  padding: 6px 12px;
  border-radius: 999px;
  text-transform: uppercase; letter-spacing: 0.12em;
  margin-bottom: 12px;
}
.featured-banner-eyebrow .dot {
  width: 8px; height: 8px; border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 0 0 4px rgba(255,95,177,0.35);
  animation: pulse 1.8s ease-in-out infinite;
}
@keyframes pulse { 50% { box-shadow: 0 0 0 8px rgba(255,95,177,0); } }
.featured-banner-title {
  font-family: var(--font-display);
  font-size: clamp(22px, 3vw, 36px);
  font-weight: 900;
  margin: 0 0 8px;
  color: white;
  text-shadow: 0 2px 12px rgba(0,0,0,0.4);
  line-height: 1.1;
}
.featured-banner-outline {
  color: rgba(255,255,255,0.92);
  font-size: 14px;
  margin: 0 0 14px;
  text-shadow: 0 1px 6px rgba(0,0,0,0.45);
  font-weight: 500;
  max-width: 540px;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.featured-banner-cta { display: flex; align-items: center; gap: 14px; }
.featured-banner-cta .meta { color: rgba(255,255,255,0.85); text-shadow: 0 1px 4px rgba(0,0,0,0.4); }
.featured-banner-logo {
  position: absolute;
  right: 32px;
  top: 50%;
  transform: translateY(-50%);
  z-index: 3;
  width: 28%;
  max-width: 320px;
  filter: drop-shadow(0 6px 18px rgba(0,0,0,0.32));
}
.featured-banner-logo img {
  width: 100%; height: auto;
  display: block;
}
@media (max-width: 720px) {
  .featured-banner { aspect-ratio: 16/10; }
  .featured-banner-content { left: 18px; bottom: 16px; max-width: 80%; }
  .featured-banner-logo { display: none; }
}

/* ---------- Song strip (latest songs scroller) ---------- */
.song-strip {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  gap: 14px;
}
.song-strip-item {
  display: block;
  transition: transform .14s ease;
}
.song-strip-item:hover { transform: translateY(-3px); }
.song-strip-art {
  aspect-ratio: 1;
  border-radius: 12px;
  overflow: hidden;
  background: var(--surface-2);
  box-shadow: var(--shadow-sm);
  border: 1px solid var(--line);
  margin-bottom: 8px;
}
.song-strip-art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.song-strip-title {
  font-weight: 800; color: var(--ink); font-size: 13px;
  line-height: 1.25;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.song-strip-sub { font-size: 11px; color: var(--muted); margin-top: 2px; }

/* ---------- Cast strip (circular character bubbles) ---------- */
.cast-strip {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(86px, 1fr));
  gap: 14px;
}
.cast-bubble {
  display: flex; flex-direction: column; align-items: center; gap: 6px;
  transition: transform .14s ease;
}
.cast-bubble:hover { transform: translateY(-3px); }
.cast-bubble-ring {
  width: 72px; height: 72px;
  border-radius: 50%;
  background: linear-gradient(135deg, var(--stripe, var(--accent)), var(--surface));
  padding: 3px;
  display: grid; place-items: center;
  box-shadow: var(--shadow-sm);
  position: relative;
}
.cast-bubble-ring::before {
  content: '';
  position: absolute; inset: 3px;
  border-radius: 50%;
  background: white;
}
.cast-bubble-ring img,
.cast-bubble-letter {
  position: relative;
  width: 64px; height: 64px;
  border-radius: 50%;
  object-fit: cover;
  display: grid; place-items: center;
  font-weight: 900; color: var(--ink); font-size: 22px;
}
.cast-bubble-name {
  font-size: 11px; color: var(--ink-soft); font-weight: 700;
  text-align: center;
  line-height: 1.2;
  max-width: 90px;
}

/* ---------- Event strip (smaller event cards in home) ---------- */
.event-strip {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 14px;
}
.event-strip-item {
  display: block;
  background: var(--surface);
  border-radius: var(--radius-md);
  overflow: hidden;
  border: 1px solid var(--line);
  box-shadow: var(--shadow-sm);
  transition: transform .14s ease, box-shadow .14s ease;
}
.event-strip-item:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); }
.event-strip-art {
  aspect-ratio: 16/9;
  background: var(--surface-2);
  overflow: hidden;
}
.event-strip-art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.event-strip-meta { padding: 10px 12px; }
.event-strip-meta h3 { font-size: 13px; margin: 0 0 2px; line-height: 1.3;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.event-strip-meta .meta { font-size: 11px; }

/* ---------- Event grid (event-stories page) ---------- */
.event-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: 16px;
}
.event-card {
  display: flex; flex-direction: column;
  background: var(--surface);
  border-radius: var(--radius-md);
  overflow: hidden;
  border: 1px solid var(--line);
  box-shadow: var(--shadow-sm);
  transition: transform .14s ease, box-shadow .14s ease;
}
.event-card:hover { transform: translateY(-3px); box-shadow: var(--shadow-md); border-color: var(--line-strong); }
.event-card-art {
  position: relative;
  /* The card art renders at its native aspect ratio — we don't force a
     box ratio because every shipped variant (eventStoryBanner ~1024×256
     primary, eventBanner 488×208 legacy fallback) was getting visibly
     clipped at the top/bottom under the previous `aspect-ratio: 1024/256`
     + `object-fit: cover` rule. Letting the image dictate height means a
     grid row that mixes a story-banner card with a home-banner-fallback
     card will have slightly uneven art-block heights, but this is the
     exception (most listings are homogeneous) and the card bodies still
     line up because each card is its own flex column. */
  background: var(--surface-2);
  overflow: hidden;
}
.event-card-art > img {
  display: block;
  width: 100%;
  height: auto;
}
.event-card-body { padding: 12px 14px 14px; }
/* C35 commit 7: bump the title up to 15px and pin the meta line to 12px.
   Previously the title rendered at 14px and the meta inherited from the
   body (~14px), which put the date+episode count visually equal to the
   title. The new sizes establish a clear hierarchy: title > meta. */
.event-card-body h3 { font-size: 15px; margin: 6px 0 2px; line-height: 1.3;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.event-card-body .meta {
  font-size: 12px;
  color: var(--ink-soft);
  font-weight: 500;
  margin-top: 4px;
}
/* C35 commit 6: the event-kind .tag-soft was lifted out from above the
   title to a trailing inline annotation inside the .meta line. Shrink the
   font and tighten the padding so it reads as a small label, not a pill
   competing with the title. */
.event-card-body .meta .tag.tag-soft {
  font-size: 9px;
  padding: 1px 6px;
  margin-left: 4px;
  vertical-align: 1px;
}

/* ---------- In-game-style Event Picker ----------
   Landscape: vertical banner rail on the left, big hero panel on the right.
   Portrait (<820px): hero on top, banner rail below.
   The visual reference is the PJSK in-game event-select screen.
*/
.event-picker {
  /* The hero occupies the entire picker area; the rail floats on top as
   * an overlay column on the left side. This lets the in-game scene art
   * read across the full panel without a separate white background column
   * behind the scroller. The grid layout was replaced with positioned
   * children so the stage and the rail can overlap. */
  position: relative;
  /* Cap the picker to the viewport height so the rail and the hero are
   * each independently scrollable instead of pushing the page below the fold.
   *
   * C23: tightened to `100vh - 350px` (was 260px under the C21 floating bar,
   * 220px before that) so the CTA + page header + page footer (bottom nav)
   * all stay in the visible viewport without scroll. The +130px over the
   * original 220 is the appbar (~64px) + #app top padding (24px) + h1 +
   * subtitle + search row + a touch of breathing room. fitRailPadding() in
   * app.js reads rail.clientHeight at runtime, so the selected banner
   * re-centers automatically against whatever height this resolves to. */
  height: clamp(520px, calc(100vh - 350px), 820px);
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: var(--surface-2);
  border: 1px solid var(--line);
}
/* C26: portrait `.event-picker` now uses the SAME clamp as landscape
 * (clamp(520px, calc(100vh - 350px), 820px)). Earlier rounds tried
 * different portrait values (200px / 240px constants) under the
 * assumption that portrait needed a taller picker to fit the
 * bottom-anchored rail strip. With the C25 rail trimmed to a thin
 * strip, that's no longer true — unifying the heights makes the picker
 * panel behave identically on either orientation, which keeps the
 * banner art consistently sized and the CTA reachable without scroll.
 *
 * We keep the @media block in place (rather than deleting it) so the
 * intent stays documented and any future portrait-specific override
 * has a home. The clamp values inside match landscape verbatim. */
@media (max-width: 819px) {
  .event-picker {
    height: clamp(520px, calc(100vh - 350px), 820px);
  }
}

.event-picker-list {
  /* Overlay the left column of the picker. Width matches the original
   * rail proportion so banner sizes/scaling stay calibrated. */
  position: absolute;
  inset: 0 auto 0 0;
  width: clamp(280px, 34%, 460px);
  z-index: 2;
  display: flex; flex-direction: column;
  gap: 8px;
  /* Horizontal padding only — vertical centering padding is set by
   * fitRailPadding() in app.js, which measures clientHeight at runtime.
   * Using `padding: 50%` here would resolve against rail width (per CSS
   * spec, vertical padding percentages also use the containing block
   * WIDTH), giving a huge value that breaks the absolute-position height
   * constraint and makes the rail overflow the picker box.
   *
   * C20: extra right padding so the SELECTED banner (which scoots right
   * by 10px via .event-picker-banner-art{left:10px}) isn't clipped by the
   * rail's right edge (= the boundary between rail and hero panel). */
  padding: 0 50px 0 18px;
  overflow-y: auto;
  background: transparent;
  /* Firefox: hide the scrollbar so the hero reads uninterrupted. The
   * companion ::-webkit-scrollbar rule below covers Blink/Safari. */
  scrollbar-width: none;
  scroll-behavior: smooth;
  /* `proximity` (not `mandatory`) lets our programmatic scrollTo land at the
   * exact computed rail-center offset on click. With mandatory, the browser
   * would re-snap to the nearest snap-point as the smooth scroll arrived,
   * often parking the banner near the top edge instead of centered. */
  scroll-snap-type: y proximity;
}
.event-picker-list::-webkit-scrollbar {
  /* WebKit/Blink: hide the scrollbar so the hero behind the rail reads
   * uninterrupted. Companion Firefox rule is scrollbar-width: none above. */
  width: 0;
  height: 0;
}
.event-picker-list .event-picker-banner {
  scroll-snap-align: center;
  scroll-snap-stop: always;
}
@media (max-width: 819px) {
  .event-picker-list {
    /* Portrait: rail is a horizontal strip pinned to the bottom of the
     * picker; the hero fills the whole panel behind it. Vertical padding
     * is fixed; horizontal centering padding is set by fitRailPadding().
     *
     * C25: trimmed the rail strip from clamp(220px, 36vh, 320px) because
     * event banner art is much wider than tall — a tall strip was mostly
     * empty space. Padding is 12px on top/sides with bottom zero, and a
     * -20px bottom margin overlaps the picker's own bottom edge slightly
     * to absorb the visual seam between strip and panel.
     *
     * C26: bumped from 120px to 128px because at 120 the selected banner
     * (which scales slightly larger than non-selected, plus the 10px
     * leftward poke that the rail offsets) was clipping at the top edge
     * of the strip. +8px is enough headroom without adding visible
     * dead space. */
    inset: auto 0 0 0;
    width: 100%;
    height: 128px;
    flex-direction: row;
    overflow-x: auto;
    overflow-y: hidden;
    padding: 12px;
    padding-bottom: 0;
    margin-bottom: -20px;
    scroll-snap-type: x proximity;
  }
}

/* Picture-only roller (C8, refined).
 *
 * Each row is just the banner art. Non-selected rows shrink their *layout
 * box* (width 72%, right-aligned) so the rail is genuinely denser and more
 * rows are visible at once; the SELECTED row takes the full rail width and
 * scales outward toward the hero. While the user is mid-scroll we flatten
 * everything via .is-scrolling so nothing pops under the finger.
 *
 * The width change does cause a layout reflow when selection changes, so
 * the click-to-scroll handler in app.js measures the target AFTER the
 * class flip is committed (via requestAnimationFrame) to land at the
 * right offset.
 */
.event-picker-banner {
  position: relative;
  display: block;
  width: 72%;
  margin-right: auto;       /* C12: left-align — left edge stays put */
  padding: 0;
  border: 0;
  background: transparent;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  overflow: visible;
  flex: 0 0 auto;
  transform-origin: left center;
  /* width changes instantly (no transition) so the rail's layout reflow
   * settles in a single frame — essential for the click-to-center scroll
   * math to land at the right offset. The visual feel of growing/shrinking
   * is carried by the transform scale.
   *
   * C20: replaced the legacy opacity + saturate combo with a single
   * brightness(0.75) for non-selected rows. Reads as a clean "dimmed but
   * full-color preview" and keeps the banner art crisp instead of muddy. */
  transition: transform .2s cubic-bezier(.2,.7,.2,1),
              filter .2s ease;
  /* BACKLOG L294 — brightness(0.75) on non-selected rows was removed
   * when the hero gained the mirrored+blurred backdrop. The ambient
   * blurred art behind the rail already provides enough visual
   * separation between the selected/unselected states, and the dim
   * filter was muddying the banner thumbnails against it. The hover
   * brightness is also dropped for the same reason; selection is now
   * carried entirely by the scale-up + width transition. */
  will-change: transform, width;
}
.event-picker-banner.selected {
  /* Full rail width + scale-up. Left-anchored origin keeps the left edge
   * aligned with the unselected rows; the scale(1.06) pushes the right
   * edge outward toward the hero panel for a subtle reveal. */
  width: 100%;
  transform: scale(1.06);
  filter: none;
  z-index: 1;
}
/* While the rail is mid-scroll, equalize size so nothing pops up under the
 * user's finger. Width is held at the unselected default; the snap on
 * scrollend re-applies the selection's full size. */
.event-picker-list.is-scrolling .event-picker-banner {
  width: 72%;
  transform: none;
  /* L294: no brightness filter (see .event-picker-banner above). */
  transition: none;
}
.event-picker-banner-art {
  display: block;
  position: relative;
  width: 100%;
  /* C20: was var(--radius-md) which is undefined — the cascade fell
   * through to 0, leaving banners with square corners. Hard-code 10px
   * for the unselected default; .selected bumps it to 14px below. */
  border-radius: 10px;
  overflow: hidden;
  background: var(--surface-2);
  aspect-ratio: 488 / 208;
  box-shadow: var(--shadow-sm);
  /* Anchors the C20 selected-state nudge (left: 10px) so the active
   * banner pops outward toward the hero panel a bit. */
  position: relative;
  left: 0;
  transition: left .2s ease, border-radius .2s ease, box-shadow .2s ease;
}
.event-picker-banner.selected .event-picker-banner-art {
  /* C20: drop the pink ring + chunky drop shadow; lean on the scale-up,
   * width pop, and the small leftward nudge to signal selection. */
  border-radius: 14px;
  box-shadow: none;
  left: 10px;
}
.event-picker-banner-art img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover; display: block;
}
@media (max-width: 819px) {
  .event-picker-banner {
    /* Portrait: row layout. Unselected rows narrower so the selected one's
     * width pop reads clearly; align-self centers them in the rail track. */
    width: 160px;
    margin-left: 0;
    align-self: center;
    transform-origin: center bottom;
    transform: translateY(0);
  }
  .event-picker-banner.selected {
    width: 240px;
    transform: scale(1.06) translateY(-4px);
  }
  .event-picker-banner.selected .event-picker-banner-art {
    /* Portrait rail is horizontal; the C20 outward "poke" reads as an
     * upward nudge instead of a leftward one. */
    left: 0;
    margin-top: -8px;
  }
  .event-picker-list.is-scrolling .event-picker-banner {
    width: 160px;
  }
  /* C20: in portrait the rail strip lives at the bottom, so the right-side
   * padding (used in landscape to give the selected banner room to scoot
   * outward) isn't needed. Restore symmetric horizontal padding. */
  .event-picker-list { padding: 12px 18px; }
}

.event-picker-stage {
  /* Fill the whole picker so the hero art reads edge-to-edge behind the
   * rail overlay. The rail sits above with z-index:2. */
  position: absolute;
  inset: 0;
  z-index: 1;
  overflow: hidden;
  background: var(--surface-2);
}

.event-picker-hero {
  position: relative;
  width: 100%; height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  isolation: isolate; /* contain z-index ordering */
}

/* BACKLOG L297 — picker hero crossfade transition.
 *
 * On selection change, updateHero() in app.js:
 *   1. Renders the new hero offscreen at opacity 0
 *      (.event-picker-hero-incoming, position:absolute over the stage).
 *   2. Waits for both the new image to decode AND the new BGM to
 *      transition past GlobalAudio.loading (Promise.race 600ms timeout).
 *   3. Flips classes to fade old→0 / new→1 in parallel.
 *   4. Removes the old hero on transitionend (or after a 400ms safety).
 *
 * Both layers transition opacity over 220ms so the swap is perceptible
 * but never lingering; the easing is `ease-out` so the incoming hero
 * "settles in" rather than the outgoing one drifting away. Keep both
 * heroes absolutely positioned at inset:0 of the stage so they stack
 * cleanly without inheriting flex-flow constraints from .event-picker-
 * hero's flex-column layout. */
.event-picker-hero-incoming,
.event-picker-hero-leaving {
  position: absolute;
  inset: 0;
  transition: opacity 220ms ease-out;
}
.event-picker-hero-incoming { opacity: 0; }
.event-picker-hero-incoming.event-picker-hero-incoming-active { opacity: 1; }
.event-picker-hero-leaving { opacity: 1; }
.event-picker-hero-leaving.event-picker-hero-leaving-active { opacity: 0; }
@media (prefers-reduced-motion: reduce) {
  /* Collapse the fade to an instant swap for users who've opted out
   * of motion. The cleanup path's setTimeout(400ms) covers the fact
   * that transitionend won't fire when transition-duration is 0. */
  .event-picker-hero-incoming,
  .event-picker-hero-leaving {
    transition: none;
  }
}

.event-picker-hero-art {
  position: absolute;
  /* BACKLOG L294 — in landscape the crisp foreground image is cropped to
   * just the area NOT covered by the rail (rail occupies left ~34%, we
   * start the foreground at 20% so a thin strip overlaps behind the
   * rail and the seam is hidden). In portrait the rail is a bottom
   * strip so the foreground stays full-bleed. The backdrop layer
   * underneath always fills inset:0 to cover the rail bleed area.
   */
  inset: 0 0 0 20%;
  z-index: 1; /* above .event-picker-hero-backdrop (z:0) */
  overflow: hidden;
  /* Soft fade-in at the LEFT edge dissolves any residual seam between
   * the crisp foreground and the blurred backdrop. 10% of the
   * foreground's width gives a gentle ramp; the rail (clamp 280px /
   * 34% / 460px wide) sits over the leftmost portion of the hero, so
   * 0–20% of the hero is pure backdrop and 20–34% is the mask fade
   * blending the crisp foreground onto the blurred backdrop directly
   * underneath the rail. */
  -webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 10%, #000 100%);
          mask-image: linear-gradient(90deg, transparent 0%, #000 10%, #000 100%);
}
@media (max-width: 819px) {
  .event-picker-hero-art {
    /* Portrait: rail is a bottom strip; cropping vertically would lose
     * more art than it gains. Restore full bleed and drop the mask. */
    inset: 0;
    -webkit-mask-image: none;
            mask-image: none;
  }
}
.event-picker-hero-art img {
  width: 100%; height: 100%;
  object-fit: cover; display: block;
}
/* BACKLOG L294 — mirrored + blurred copy of the active hero image,
 * fills the area BEHIND the cropped foreground (and the entire hero in
 * portrait, where the foreground is full-bleed and the backdrop just
 * supplies ambient color underneath the bottom rail strip). The
 * horizontal flip (scaleX(-1)) means the foreground's left edge meets
 * the backdrop's MIRRORED right edge — different content, so even an
 * imperfect mask line doesn't reveal a duplicated subject. The extra
 * translateX(-25%) shifts the mirror's centerline seam off to the
 * right where it ends up underneath the foreground (which starts at
 * left:20% in landscape), and the heavy blur dissolves any residual
 * discontinuity over a ~32px radius. transform-origin:center keeps the
 * scale and flip composed around the same anchor. The .src of the
 * backdrop img is kept in sync with the active foreground by
 * wireHeroArt() in public/js/app.js. */
.event-picker-hero-backdrop {
  position: absolute;
  inset: 0;
  z-index: 0; /* below .event-picker-hero-art (z:1) */
  overflow: hidden;
  pointer-events: none;
}
.event-picker-hero-backdrop img {
  /* Width is 80% (not 100%) and translated right via translateX(60%)
   * in the mirrored coordinate system. After scaleX(-1) flips the
   * x-axis, a positive translateX in CSS source moves the image to
   * the LEFT in screen coordinates. The combination places the
   * backdrop's mirrored content under the rail-overlap region (the
   * left 0–34% of the hero) where it's actually visible, instead of
   * spreading the same blurred image across the whole hero where the
   * foreground already covers everything from x=20% rightward. With
   * the lighter blur (8px instead of 32px) the backdrop reads as a
   * deliberate ambient extension of the artwork rather than a fully
   * dissolved color wash. */
  width: 80%; height: 100%;
  object-fit: cover;
  display: block;
  transform: scaleX(-1) translateX(60%);
  /* L294: kept the blur radius minimal so the backdrop reads as a
   * deliberate ambient extension of the artwork. saturate/brightness
   * tweaks were removed — the mirrored copy now matches the foreground
   * exactly in tone so the seam under the rail is invisible. */
  filter: blur(8px);
}
@media (max-width: 819px) {
  .event-picker-hero-backdrop img {
    /* Portrait: foreground is full-bleed; the backdrop is only visible
     * during the image load gap and at the soft mask edges. Same
     * transform as landscape — the rule is repeated here so any future
     * portrait-specific tuning has an explicit home. */
    transform: scaleX(-1) translateX(60%);
  }
}
/* Two stacked layers used for the WL cross-fade (C10). .is-active is
 * visible, the other sits behind at opacity 0 waiting for the next swap. */
.event-picker-hero-img {
  position: absolute;
  inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity .9s ease;
}
.event-picker-hero-img.is-active {
  opacity: 1;
}
/* BACKLOG L294 — the .event-picker-hero-scrim layer that previously
 * sat over the rail-overlap region was removed when the hero gained
 * the mirrored+blurred backdrop. The backdrop's blur already softens
 * the area behind the rail enough for banner contrast, so the
 * additional dark gradient was double-dimming and not pulling its
 * weight. Title/meta legibility continues to rely on the existing
 * `text-shadow: 0 1px 3px rgba(0,0,0,0.45)` carried by .event-picker-hero-info
 * text. If a future iteration needs the scrim back, the historical
 * spec (rail-mirror clamp(280px, 34%, 460px) landscape / 128px-tall
 * bottom strip portrait) lives in `git log -p` around L293. */

/* L294 — banners are <button> elements so they receive a default
 * focus ring on keyboard / Tab activation. The picker manages its
 * own visual selection state via `.selected` (scale-up + width grow),
 * and the focus ring stacked an extra blue/white outline on top of
 * the rail-selected banner that read as visual noise — especially
 * against the mirrored+blurred hero backdrop. Suppress only the
 * default outline; the .selected styling continues to indicate
 * focus + selection unambiguously. */
.event-picker-banner:focus-visible {
  outline: none;
}
.event-picker-hero-info {
  position: relative;
  z-index: 1;
  /* Left padding pushes the title/meta clear of the rail overlay (which
   * sits at width clamp(280px, 34%, 460px) on the left edge). Right pad
   * bumped to 56px to leave room for the absolute-positioned CTA pinned
   * to bottom: 22px / right: 22px (C20).
   *
   * C21: bottom pad reduced 64 → 22 so the natural content baseline (end
   * of pill row) aligns with the CTA's bottom edge. The CTA's absolute
   * `bottom: 22px` is measured from the padding-box bottom, so a 22px
   * padding leaves the CTA flush with where flex content would end. */
  padding: 18px 22px 22px calc(clamp(280px, 34%, 460px) + 22px);
  color: #fff;
  display: flex; flex-direction: column; gap: 10px;
  /* C20: dropped max-width so the title/outline/pill-row can use the full
   * available horizontal space. The intrinsic line-clamps + scrim handle
   * the legibility instead of an artificial column. */
  text-shadow: 0 1px 3px rgba(0,0,0,0.45);
  /* The hero info block spans the full stage width, but most of its area
   * is empty padding sitting on top of the rail. Let clicks fall through
   * to the rail banners; re-enable on interactive children only. */
  pointer-events: none;
}
.event-picker-hero-info > * { pointer-events: auto; }
@media (max-width: 819px) {
  .event-picker-hero-info {
    /* Portrait: rail strip is at the bottom, so the text gets bottom
     * padding to clear it instead of left padding.
     *
     * C25: bottom pad bumped to 140px (was rail-height + 22px = ~242px+);
     * with the C25 rail-strip trim to 120px, 140px now leaves the CTA
     * sitting ~20px above the rail's top edge — enough breathing room
     * without wasted space. Also set height: 100% so the info column
     * fills the hero panel vertically, which gives `order:` on the flex
     * children a stable height to distribute against (without it the
     * column collapses to content height in some browsers). */
    padding: 18px 22px 140px 22px;
    height: 100%;
  }
  /* C25: reorder the hero-info children so the pill-row sits VISUALLY
   * ABOVE the logo in portrait. The DOM order is logo → pill-row → CTA;
   * `order:` lets us flip just the visual stacking without touching the
   * renderer. The CTA stays last so it pins to the bottom-right via the
   * existing position: absolute rule. */
  .event-picker-hero-info .pill-row { order: 1; }
  .event-picker-hero-info .event-picker-hero-logo { order: 2; }
  .event-picker-hero-info .event-picker-cta { order: 3; }
}
/* C22: the hero-info h2 and .tag-soft were lifted out of the hero into
 * .event-picker-title (rendered above the picker, inside .search-row).
 * The old hero-scoped rules are kept ONLY for the empty-state hero
 * (.event-picker-hero-empty), which still owns its own h2 inside. */
.event-picker-hero-empty .event-picker-hero-info h2 {
  font-size: clamp(18px, 2.2vw, 26px);
  margin: 0;
  line-height: 1.25;
  color: #fff;
}

/* ---------- C22: lifted-out picker title row (C24: reordered) ----------
 *
 * Lives inside .search-row alongside the search input. The row order is:
 *   [search-input]  …  [event-title h2]  [event-type tag pill]
 *
 * C22 originally placed the title block on the leading edge with the
 * search on the trailing edge (margin: 0 auto 0 0). C24 flipped that:
 * the search input is now the leading element so it's the first thing a
 * user reads/tabs to, and the title block floats to the trailing edge
 * via `margin-left: auto`. The h2 is sized down from the in-hero clamp
 * (was 18–26px) to match the chrome-row scale; the tag is rendered as a
 * proper surface-tinted pill (see .event-picker-title .tag.tag-soft
 * below) so it reads as a clearly separate metadata chip rather than
 * unstyled text after the event name. */
.event-picker-title {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  margin: 0 0 0 auto;  /* C24: leading auto pushes the title block right */
  min-width: 0;        /* allow the h2 to ellipsize inside flex */
}
.event-picker-title.is-empty {
  /* Reserve a small footprint so the search input doesn't pop to the
   * right edge of the row when results are empty. Just enough to hold
   * the tag-pill's typical width. */
  min-width: 80px;
  height: 1px;
}
.event-picker-title h2 {
  font-size: clamp(16px, 1.8vw, 22px);
  margin: 0;
  line-height: 1.2;
  color: var(--ink);
  font-weight: 700;
  /* Single-line; ellipsize so a long event name doesn't push the search
   * input down to a second row on narrow viewports. */
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}
.event-picker-title .tag.tag-soft {
  /* C24: explicit pill-container styling. The base .tag selector is only
   * defined inside `.card .tag` in this stylesheet, so an unscoped
   * `<span class="tag tag-soft">` outside a card has no inherent pill
   * look. We apply it here so the event-type chip after the title reads
   * as a clearly separate metadata pill instead of a string of text
   * trailing the h2. Palette mirrors `.card .tag.tag-soft`: surface-2
   * fill, ink-soft text, hairline border, uppercase tracking. */
  display: inline-flex;
  align-items: center;
  flex-shrink: 0;
  font-size: 11px;
  padding: 3px 10px;
  border-radius: var(--radius-pill);
  background: var(--surface-2);
  color: var(--ink-soft);
  border: 1px solid var(--line);
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  /* No box-shadow — the chip sits inside the chrome row, not floating. */
  box-shadow: none;
}
@media (max-width: 560px) {
  /* Narrow viewports: shrink the chip to leave room for the event name.
   * We keep the pill shape so it still reads as a metadata chip. */
  .event-picker-title .tag.tag-soft {
    font-size: 10px;
    padding: 2px 6px;
  }
  .event-picker-title h2 { font-size: 14px; }
}
.event-picker-hero-logo {
  /* L297 follow-up: the explicit `width: 50%` constraint was removed.
   * The child <img> now scales itself via transform: scale(0.5) from a
   * corner origin, so this wrapper just needs to be a block-level
   * positioning context for the drop-shadow filter. Letting the
   * wrapper take its content's natural width means each event's logo
   * occupies space proportional to its intrinsic asset size instead
   * of every wrapper claiming 50% of the info column. */
  filter: drop-shadow(0 3px 8px rgba(0,0,0,0.55));
}
.event-picker-hero-logo img {
  /* The intrinsic logo PNG is rendered at its natural size and scaled
   * down 50% from the bottom-left corner so the logo anchors to the
   * info-block's lower-left edge (next to the title) in landscape. The
   * width/height/object-fit triple was previously forcing the img to
   * fill its 50%-wide container with object-fit:contain, which always
   * upscaled small logos and downscaled large ones to the same
   * silhouette — we want the logos at their natural aspect AND scale
   * relative to each other, so each event's logo reads at a size that
   * matches the asset designer's intent. */
  display: block;
  transform: scale(0.5);
  transform-origin: bottom left;
}
@media (max-width: 819px) {
  .event-picker-hero-logo img {
    /* Portrait: the hero-info column is reordered (pill-row → logo →
     * CTA, see L2425-2432) so the logo sits in the MIDDLE of the
     * column rather than at the bottom. Anchor it to top-left so the
     * scale doesn't drift the logo downward into the CTA underneath. */
    transform-origin: top left;
  }
}
.event-picker-hero-outline {
  margin: 0;
  font-size: 13px;
  line-height: 1.45;
  color: rgba(255,255,255,0.92);
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.event-picker-hero-info .pill-row {
  margin-top: 4px;
}
/* Pill needs to read against bright artwork as well as the dark scrim,
   so we lean on a darker glass-effect instead of translucent white.
   Selector intentionally includes .pill-row to outweigh the global
   `.pill-row .pill` rule defined further down the stylesheet. */
.event-picker-hero-info .pill-row .pill {
  background: rgba(8,12,20,0.55);
  border-color: rgba(255,255,255,0.22);
  color: #fff;
  text-shadow: none;
  backdrop-filter: blur(6px);
  box-shadow: 0 2px 10px rgba(0,0,0,0.25);
}
.event-picker-hero-info .pill-row .pill .label { color: rgba(255,255,255,0.82); }
.event-picker-hero-info .pill-row .pill strong { color: #fff; }

/* C20: CTA refactor.
 *
 *  - Color changes from PJSK pink to the in-game mint #84ebde so it reads
 *    as the canonical "Select" affordance.
 *  - Pinned via position: absolute to the bottom-right of
 *    .event-picker-hero-info (which is position: relative). This is the
 *    cleanest answer to "is absolute positioning the best way?": yes,
 *    because the surrounding logo / h2 / pill-row stay in their natural
 *    flex flow and the CTA no longer claims its own row at the bottom.
 *    In portrait the info block's larger bottom padding (clears the rail
 *    strip) still works because we re-anchor the CTA above the rail in
 *    the @media (max-width: 819px) block.
 *  - Wider padding for the in-game shape; the trailing "›" arrow is gone
 *    (handled in public/js/event-stories/picker.js).
 */
.event-picker-cta {
  position: absolute;
  right: 22px;
  bottom: 22px;
  display: inline-flex; align-items: center;
  /* C21: slimmer vertical pad (6 vs 12) so the pill matches the in-game
   * proportions more closely; horizontal pad widened (48 vs 32) to keep
   * the touch target generous despite the shorter height. */
  padding: 6px 48px;
  border-radius: 999px;
  background: #84ebde;
  color: #14323d;
  font-weight: 800;
  letter-spacing: 0.04em;
  text-decoration: none;
  box-shadow: 0 6px 16px rgba(132, 235, 222, 0.32);
  transition: transform .12s ease, box-shadow .12s ease, background .12s ease;
  text-shadow: none;
}
.event-picker-cta:hover {
  background: #9bf0e6;
  transform: translateY(-1px);
  box-shadow: 0 10px 22px rgba(132, 235, 222, 0.45);
}
@media (max-width: 819px) {
  /* Portrait: hero-info bottom padding clears the rail strip; pin the CTA
   * just above that padding zone so it never overlaps the bottom rail.
   *
   * C25: bottom: 140px (was calc(clamp(220px, 36vh, 320px) + 12px) =
   * 232px-332px). The new value matches the .event-picker-hero-info
   * bottom padding so the CTA sits flush with where flex content ends,
   * which is now well above the trimmed 120px rail strip. */
  .event-picker-cta {
    bottom: 140px;
  }
}
.event-picker-hero-empty {
  justify-content: center;
  align-items: center;
  text-align: center;
  background: var(--surface);
  color: var(--text);
}
.event-picker-hero-empty .event-picker-hero-info {
  text-shadow: none;
  color: var(--text);
  align-items: center;
}

/* ---------- In-game-style Episode Picker (C5) ----------
   Hero on the left, vertical list of episode rows on the right.
   Portrait stacks: hero on top, episode list below.
   Used by both #/event-stories/:id and #/unit-stories/:unit/:chapterId.
*/
.episode-picker {
  /* C3 — 50-50 split (was minmax(280px, 38%) 1fr). The wider right-
     side panel got cramped once the in-game-style hero grew its
     fallback-image chain and song-jacket logo, so the symmetric
     1fr 1fr layout reads better at both desktop and landscape-mobile.
     Portrait still stacks via the media query below. */
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 18px;
  align-items: stretch;
  height: clamp(520px, calc(100vh - 220px), 820px);
  border-radius: var(--radius-lg);
  overflow: hidden;
  border: 1px solid var(--line);
  background: var(--surface-2);
}
@media (max-width: 819px) {
  .episode-picker {
    grid-template-columns: 1fr;
    /* Mobile-portrait outline hoist — a 3-row grid (hero / outline /
       list) so the event-detail outline blurb sits between the hero and
       the episode list. Was a 2-row grid (hero / list) with the outline
       living inside `.episode-hero-info`; on small phones the outline
       ate the hero's vertical budget and pushed the song jacket +
       episode rows offscreen. `auto` for the outline lets the row
       collapse to zero on BGM-only events (no outline string) without
       leaving a phantom gap — the section element has `display:none`
       when empty (handled below). */
    grid-template-rows: minmax(220px, 36vh) auto 1fr;
    height: clamp(560px, calc(100vh - 200px), 900px);
  }
}

/* Mobile-portrait outline hoist — the section-level outline.

   `.episode-picker-outline` is emitted by `episodePickerHtml` as a
   sibling of `.episode-picker-hero-slot` and `.episode-picker-list`,
   carrying a duplicate of the event-story blurb that lives inside
   `.episode-hero-info` on desktop. It is hidden by default (desktop
   and landscape) and only revealed on portrait, where the in-hero
   copy is hidden in turn — see the @media block below for the swap.

   We never show both copies at once: `display: none` is used (not
   visibility / opacity) so the inactive one is also removed from the
   accessibility tree. The wrapper element is on its own row of the
   picker grid; padding keeps the text off the panel edges so it
   visually reads as a continuation of the hero color (no banner
   here, just the page background). */
.episode-picker-outline {
  display: none;
}
.episode-picker-outline .event-detail-outline.is-section {
  /* The shared `.event-detail-outline` rule below already sets the
     font-size and line-height; we override only the spacing here so
     the section variant breathes inside its own grid row. */
  margin: 0;
  padding: 14px 18px 16px;
  color: var(--text);
  background: var(--surface-2);
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  text-shadow: none;
}
@media (max-width: 819px) {
  /* Show the section-level outline and hide the in-hero one. The
     in-hero copy is the descendant of `.episode-hero-info` so this
     selector targets ONLY that copy — the section variant on the
     same page (sibling, not descendant) stays visible. */
  .episode-picker-outline {
    display: block;
  }
  .episode-hero-info > .event-detail-outline {
    display: none;
  }
}

.episode-picker-hero-slot { position: relative; overflow: hidden; }
.episode-hero {
  position: relative;
  width: 100%; height: 100%;
  display: flex; flex-direction: column;
  justify-content: flex-end;
  isolation: isolate;
  /* When there is no banner art (unit stories) fall back to a
     unit-tinted gradient driven by the --stripe custom property. */
  background:
    linear-gradient(135deg, color-mix(in srgb, var(--stripe, #888) 70%, #fff 30%) 0%,
                            color-mix(in srgb, var(--stripe, #888) 35%, #fff 65%) 100%);
}
.episode-hero.has-art { background: var(--surface-2); }
.episode-hero-art { position: absolute; inset: 0; z-index: 0; }
.episode-hero-art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.episode-hero-art-color { background: transparent; }
.episode-hero-scrim {
  position: absolute; inset: 0;
  background: linear-gradient(180deg, rgba(8,12,20,0.0) 0%, rgba(8,12,20,0.25) 55%, rgba(8,12,20,0.78) 100%);
  pointer-events: none;
}
/* C-U5 — foreground cutout layer (unit-stories group art). Sits above
   the backdrop + scrim (z:0 sibling, later in DOM order) but below the
   info column (z:1). C-U7.1: spans the full hero width and uses
   object-fit: cover (centered) so the group composition fills the hero
   instead of shrinking into a small corner cluster. The source images
   (story/title_image/{slug}/story_title_image.webp) are wide group
   compositions — cropping their edges is preferable to having them
   appear thumbnail-sized. pointer-events:none so the art never blocks
   clicks on the back-pill or any future hero actions. */
.episode-hero-foreground {
  position: absolute;
  inset: 0;
  width: 100%;
  z-index: 0;
  pointer-events: none;
  display: flex;
  align-items: center;
  justify-content: center;
}
.episode-hero-foreground img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  display: block;
  filter: drop-shadow(0 8px 18px rgba(0, 0, 0, 0.4));
}
.episode-hero-info {
  position: relative; z-index: 1;
  padding: 18px 22px 22px;
  color: #fff;
  display: flex; flex-direction: column; gap: 8px;
  /* C3.2 — was max-width: min(560px, 92%). When the column held just
     title + meta the cap kept long titles from stretching, but now
     that the outline blurb also lives in here the column wants the
     full hero-slot width to breathe. Padding (22px each side) still
     keeps text off the hero edge, and the picker's 50/50 grid keeps
     the slot itself bounded — so 100% is the right ceiling. */
  max-width: 100%;
  text-shadow: 0 1px 3px rgba(0,0,0,0.5);
}
/* C3.1 — the back button used to be a small dark-glass pill living
   inside the hero info column. It now sits as a sibling of
   .episode-hero-info, pinned absolutely to the top-left of the hero
   (mirroring how .event-picker-cta pins to the bottom-right of the
   event-picker hero). The visual shape matches that CTA — same
   border-radius, vertical padding, font-weight, letter-spacing,
   box-shadow — but with a white background + dark text so the two
   buttons read as inverse counterparts (CTA = brand-mint forward,
   Back = white back).

   The legacy small-dark-pill is preserved for the color-mode unit-
   story hero further down (.episode-hero:not(.has-art) override) —
   the bold white pill would compete with the pastel gradient there. */
.episode-hero-back {
  position: absolute;
  top: 18px;
  left: 18px;
  z-index: 2;
  display: inline-flex; align-items: center;
  padding: 6px 24px;
  border-radius: 999px;
  background: #ffffff;
  color: #14323d;
  font-weight: 800;
  font-size: 13px;
  letter-spacing: 0.04em;
  text-decoration: none;
  box-shadow: 0 6px 16px rgba(8, 12, 20, 0.35);
  transition: transform .12s ease, box-shadow .12s ease, background .12s ease;
  text-shadow: none;
}
.episode-hero-back:hover {
  background: #f1f5f9;
  transform: translateY(-1px);
  box-shadow: 0 10px 22px rgba(8, 12, 20, 0.45);
}
.episode-hero-info .tag.tag-soft {
  align-self: flex-start;
  background: rgba(255,255,255,0.18);
  color: #fff;
  border: 1px solid rgba(255,255,255,0.28);
  backdrop-filter: blur(6px);
  text-shadow: none;
}
.episode-hero-subtitle {
  font-size: 12px;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: rgba(255,255,255,0.85);
}
.episode-hero-logo {
  width: clamp(160px, 50%, 280px);
  max-height: 110px;
  filter: drop-shadow(0 3px 8px rgba(0,0,0,0.55));
}
.episode-hero-logo img {
  width: 100%; height: 100%;
  object-fit: contain; display: block;
}
/* C3.1 — song meta block (title + composer) lives where the event-type
   tag used to sit, just below the logo/jacket. Title is the louder line,
   composer is small and uppercase to match the in-game caption style. */
.episode-hero-meta {
  display: flex;
  flex-direction: column;
  gap: 2px;
  margin-top: 2px;
  /* C3.2c — subtle hairline below the composer line to visually
     separate the song block (jacket + title + composer) from the
     event block (hero title + outline). The line sits on the meta
     block itself (not the hero title) so it only appears when a
     canonical song actually resolved — BGM-only events that skip
     heroMeta also skip the separator, no orphan rule. The flex
     container's own gap (8px) handles the breathing room above
     and below the line; padding-bottom adds a touch more space
     between the composer text and the rule itself so the line
     doesn't sit right under the descenders. */
  padding-bottom: 8px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.18);
}
/* Color-mode (unit story / no banner art) hero uses a pastel
   gradient with dark text — invert the separator tint so it reads
   instead of disappearing. */
.episode-hero:not(.has-art) .episode-hero-meta {
  border-bottom-color: rgba(20, 24, 40, 0.18);
}
.episode-hero-meta-title {
  font-size: clamp(14px, 1.5vw, 17px);
  font-weight: 700;
  color: rgba(255,255,255,0.96);
  line-height: 1.25;
}
.episode-hero-meta-composer {
  font-size: 11px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: rgba(255,255,255,0.78);
}
.episode-hero:not(.has-art) .episode-hero-meta-title    { color: #1f1f2e; }
.episode-hero:not(.has-art) .episode-hero-meta-composer { color: #4a4a5c; }

/* C3 — song-jacket variant. Music jackets are 1:1 (740×740 source),
   so the slot becomes a square instead of the wide event-logo aspect.
   Slightly smaller cap so it doesn't dominate the hero info column,
   and a tighter drop-shadow since jackets are denser than logos. */
.episode-hero-logo.is-jacket {
  width: clamp(96px, 24%, 168px);
  aspect-ratio: 1 / 1;
  max-height: none;
  border-radius: 10px;
  overflow: hidden;
  filter: drop-shadow(0 4px 10px rgba(0,0,0,0.6));
}
.episode-hero-logo.is-jacket img {
  width: 100%; height: 100%;
  object-fit: cover; display: block;
}
/* BACKLOG-L311 — song-jacket click-through: the anchor that wraps the
   jacket <img> when the event has a resolved canonical song. Fills the
   .is-jacket square completely so the entire jacket is clickable; the
   hover affordance is a subtle scale + pointer cursor, mirroring the
   .music-card and .episode-row interactions used elsewhere. The transform
   sits on the <a> (not the inner <img>) so the drop-shadow on the
   outer .episode-hero-logo wrapper stays still while only the clickable
   surface lifts. The :focus-visible ring matches the global focus style
   the appbar buttons use, scoped tightly so the keyboard outline lands
   on the jacket itself rather than the outer wrapper. */
.episode-hero-logo-link {
  display: block;
  width: 100%; height: 100%;
  cursor: pointer;
  transition: transform 0.15s ease-out;
}
.episode-hero-logo-link:hover {
  transform: scale(1.04);
}
.episode-hero-logo-link:focus-visible {
  outline: 2px solid #7ad9ff;
  outline-offset: 2px;
}
.episode-hero-logo-link img {
  pointer-events: none; /* clicks always hit the anchor, never the img */
}
.episode-hero-title {
  font-size: clamp(20px, 2.4vw, 28px);
  line-height: 1.2;
  margin: 2px 0 0;
  color: #fff;
}
/* Unit-tinted hero (no banner art) sits on a pastel gradient, so
   the text needs darker shadows than the dark-scrim version. */
.episode-hero:not(.has-art) .episode-hero-info {
  color: #111;
  text-shadow: 0 1px 2px rgba(255,255,255,0.6);
}
.episode-hero:not(.has-art) .episode-hero-title { color: #111; }
.episode-hero:not(.has-art) .episode-hero-subtitle { color: #2b2b3c; }
/* C3.1 — color-mode (unit-story) hero: revert the back button to the
   smaller translucent pill so it doesn't blast a bright white block
   over the pastel unit gradient. Position stays absolute top-left,
   inherited from the image-mode rule above. */
.episode-hero:not(.has-art) .episode-hero-back {
  padding: 4px 14px;
  background: rgba(255,255,255,0.72);
  color: #2b2b3c;
  font-weight: 600;
  font-size: 12px;
  box-shadow: 0 2px 6px rgba(0,0,0,0.1);
  border: 1px solid rgba(0,0,0,0.08);
  text-shadow: none;
}
.episode-hero:not(.has-art) .episode-hero-back:hover {
  background: #fff;
  box-shadow: 0 4px 10px rgba(0,0,0,0.18);
}
.episode-hero:not(.has-art) .tag.tag-soft {
  background: rgba(255,255,255,0.7);
  color: #2b2b3c;
  border-color: rgba(0,0,0,0.08);
  text-shadow: none;
}

.episode-picker-list {
  padding: 14px;
  background: var(--surface);
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 10px;
  scrollbar-gutter: stable;
}
.episode-list-empty {
  padding: 24px;
  color: var(--text-soft, #88809a);
  text-align: center;
  font-style: italic;
}

.episode-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 12px 16px;
  border-radius: var(--radius-md);
  background: var(--surface);
  border: 1px solid var(--line);
  box-shadow: var(--shadow-sm);
  color: inherit;
  text-decoration: none;
  transition: transform .12s ease, box-shadow .12s ease, border-color .12s ease;
}
.episode-row:hover {
  transform: translateY(-1px);
  border-color: var(--line-strong);
  box-shadow: var(--shadow-md);
}
.episode-row-text {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.episode-row-label {
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-soft, #88809a);
}
.episode-row-title {
  font-size: 16px;
  font-weight: 600;
  line-height: 1.3;
  color: var(--text, #1a1a2e);
  /* Allow long titles to wrap, but don't blow the row up vertically. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.episode-row-meta {
  font-size: 11px;
  color: var(--text-soft, #88809a);
  margin-top: 2px;
}
.episode-row-meta code {
  font-size: 11px;
  background: var(--surface-2);
  padding: 1px 5px;
  border-radius: 4px;
}
.episode-row-chev {
  flex: 0 0 auto;
  font-size: 22px;
  line-height: 1;
  color: var(--text-soft, #88809a);
  transition: transform .12s ease, color .12s ease;
}
.episode-row:hover .episode-row-chev {
  transform: translateX(2px);
  color: var(--accent, #ff5fb1);
}

/* C15 — per-episode thumbnail on event-story rows. Sits between text and
   chevron. 16:9 aspect honors the source asset's framing. The `.is-broken`
   class is toggled by the row's inline onerror when the CDN 404s so the
   row collapses cleanly to text-only. */
.episode-row-art {
  flex: 0 0 auto;
  width: 96px;
  aspect-ratio: 16 / 9;
  border-radius: var(--radius-sm, 6px);
  overflow: hidden;
  background: var(--surface-2, #f1eef7);
  box-shadow: var(--shadow-sm);
  display: block;
}
.episode-row-art img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.episode-row-art.is-broken { display: none; }
@media (max-width: 560px) {
  .episode-row-art { width: 72px; }
}

.event-detail-outline {
  margin-top: 18px;
  font-size: 14px;
  line-height: 1.5;
}

/* ---------- Event detail hero ---------- */
.event-detail-hero {
  display: grid;
  grid-template-columns: minmax(0, 1.2fr) 1fr;
  gap: 28px;
  align-items: center;
  margin-bottom: 18px;
}
.event-detail-banner {
  position: relative;
  aspect-ratio: 16/9;
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: var(--surface-2);
  border: 1px solid var(--line);
  box-shadow: var(--shadow-md);
}
.event-detail-banner > img { width: 100%; height: 100%; object-fit: cover; display: block; }
.event-detail-logo {
  position: absolute;
  left: 20px; bottom: 18px;
  width: 40%; max-width: 200px;
  filter: drop-shadow(0 4px 10px rgba(0,0,0,0.5));
}
.event-detail-logo img { width: 100%; }
.event-detail-info h1 { font-size: clamp(22px, 2.6vw, 32px); margin-top: 8px; }
@media (max-width: 880px) {
  .event-detail-hero { grid-template-columns: 1fr; }
}

.pill-row {
  display: flex; flex-wrap: wrap; gap: 8px;
  margin-top: 12px;
}
.pill-row .pill {
  background: var(--surface-2);
  border-radius: 999px;
  padding: 8px 14px;
  font-size: 12px;
  border: 1px solid var(--line);
  box-shadow: var(--shadow-sm);
  display: inline-flex; align-items: center;
}

/* ---------- Character tile (with portrait) ---------- */
.char-tile {
  position: relative;
  background: var(--surface);
  border-radius: var(--radius-md);
  border: 1px solid var(--line);
  overflow: hidden;
  aspect-ratio: 3 / 4;
  box-shadow: var(--shadow-sm);
  transition: transform .15s ease, box-shadow .15s ease;
  isolation: isolate;
}
.char-tile:hover { transform: translateY(-4px); box-shadow: var(--shadow-lg); }
.char-tile-bg {
  position: absolute; inset: 0;
  background:
    radial-gradient(ellipse at 50% 0%, color-mix(in srgb, var(--stripe) 18%, transparent), transparent 65%),
    linear-gradient(180deg, var(--surface) 0%, var(--surface-2) 100%);
  z-index: 1;
}
.char-tile::before {
  content: '';
  position: absolute; top: 0; left: 0; right: 0;
  height: 5px;
  background: linear-gradient(90deg, var(--stripe), color-mix(in srgb, var(--stripe) 50%, white));
  z-index: 5;
}
.char-tile-portrait {
  position: absolute;
  inset: 0 0 65px 0;
  z-index: 2;
  display: flex; align-items: flex-end; justify-content: center;
  overflow: hidden;
}
.char-tile-portrait img {
  max-height: 110%;
  max-width: 130%;
  object-fit: contain;
  filter: drop-shadow(0 6px 14px rgba(20, 30, 80, 0.18));
  transform: translateY(8%);
}
.char-tile-icon {
  position: absolute;
  left: 12px; top: 12px;
  z-index: 4;
  width: 44px; height: 44px;
  border-radius: 12px;
  background: white;
  border: 2px solid var(--stripe);
  box-shadow: var(--shadow-sm);
  overflow: hidden;
  display: grid; place-items: center;
}
.char-tile-icon img { width: 100%; height: 100%; object-fit: cover; }
.char-tile-icon span { font-weight: 900; color: var(--ink); font-size: 18px; }
.char-tile-info {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  z-index: 3;
  padding: 12px 14px 14px;
  background: linear-gradient(180deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.96) 32%);
  text-align: left;
}
.char-tile-name-jp { font-weight: 800; color: var(--ink); font-size: 14px; line-height: 1.15; }
.char-tile-name-en {
  color: var(--muted); font-size: 10px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  font-weight: 700;
  margin-top: 2px;
}

/* Override the old char-grid to be a bit bigger now that we have portraits */
.char-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
  gap: 16px;
}

/* Unit-coloured accent bar (per-unit gradient) */
.accent-bar-unit {
  background: linear-gradient(180deg, var(--stripe), color-mix(in srgb, var(--stripe) 50%, white)) !important;
}

/* ========== C-U11 — /unit-stories index tiles ============================ */
/* Six split-card tiles (one per unit) replacing the old per-unit section
   header + single-card layout. Each tile is a 50/50 horizontal split:
   chapter1 group-banner art on the left, unit name + chapter kind +
   episode count on the right.

   The art layer is the in-game story/chapter_image/chapter1/{bundle}.webp
   (350×250 transparent webp). object-fit: cover crops gracefully across
   the various unit compositions while keeping faces visible.

   Layout: 3-up on wide desktop, 2-up on tablet (≤980), 1-up on phone
   (≤560) — matching house breakpoints. A unit-coloured top stripe
   (--stripe) ties each tile to its unit's identity (same pattern as
   .char-tile::before and .unit-stripe on the legacy card). */
.unit-story-tile-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 18px;
  margin-top: 12px;
}
@media (max-width: 980px) {
  .unit-story-tile-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 560px) {
  .unit-story-tile-grid { grid-template-columns: 1fr; }
}

.unit-story-tile {
  position: relative;
  display: grid;
  grid-template-columns: 1fr 1fr;          /* 50/50 split */
  align-items: stretch;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: 12px;
  overflow: hidden;
  text-decoration: none;
  color: inherit;
  box-shadow: var(--shadow-sm);
  isolation: isolate;
  transition: transform .15s ease, box-shadow .15s ease, border-color .15s ease;
}
.unit-story-tile:hover {
  transform: translateY(-3px);
  box-shadow: var(--shadow-lg);
  border-color: color-mix(in srgb, var(--stripe, var(--line)) 40%, var(--line));
}
/* Unit-coloured top stripe — matches .char-tile::before in spirit so the
   /unit-stories index reads as part of the same visual family. */
.unit-story-tile::before {
  content: '';
  position: absolute; top: 0; left: 0; right: 0;
  height: 4px;
  background: linear-gradient(90deg, var(--stripe), color-mix(in srgb, var(--stripe) 50%, white));
  z-index: 3;
}

.unit-story-tile-art {
  position: relative;
  background:
    radial-gradient(ellipse at 50% 40%, color-mix(in srgb, var(--stripe) 20%, transparent), transparent 70%),
    color-mix(in srgb, var(--stripe) 8%, var(--surface-2));
  overflow: hidden;
}
.unit-story-tile-art img {
  width: 100%; height: 100%;
  object-fit: cover;
  object-position: center 30%;              /* favour faces over feet */
  display: block;
}
.unit-story-tile-art img.img-broken { visibility: hidden; }

.unit-story-tile-info {
  display: flex; flex-direction: column;
  justify-content: center;
  gap: 6px;
  padding: 18px 20px;
  min-width: 0;                             /* lets long unit names ellipsize */
}
.unit-story-tile-unit {
  margin: 0;
  font-size: 18px;
  font-weight: 800;
  line-height: 1.2;
  color: var(--ink);
  word-break: break-word;
}
.unit-story-tile-kind {
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.02em;
  color: color-mix(in srgb, var(--stripe) 70%, var(--ink));
}
.unit-story-tile-meta {
  font-size: 12px;
  color: var(--muted);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

/* ========== C30 Character-select panel (in-game style) =================== */
/* Imitates the in-game "Please select a character" screen. Each panel is
   a tall (3:8) vertical card with a unit-coloured backplate band, the
   chr_tl_{id} portrait layered on top with a soft drop shadow, the
   character's English given+family name written vertically as a
   watermark on the right, and a frosted name plate at the bottom carrying
   the big given name, the smaller full name, the outlined unit logo, and
   the unit-colour footer stripe. */
.char-unit-section { margin-bottom: 32px; }
.char-unit-section h2 { display: flex; align-items: center; gap: 10px; }
/* C33b: The chr_tl_{id} asset already bakes in the art + sideways
   watermark + bottom JP/EN name plate, so the panel itself is mostly
   a frame around the asset plus a small overlay. Panels are adaptive:
   we pin the asset height and let width follow the image's intrinsic
   aspect — VS chars (240x840, ratio 0.286) render visibly narrower
   than scout-unit chars (376x840, ratio 0.448), matching the in-game
   grid. Because of this the grid is auto-flow, not a fixed-column grid. */
.char-panel-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
}
.char-panel {
  position: relative;
  display: block;
  /* Height-pinned; width follows the .char-panel-art inside via natural
     aspect of the chr_tl asset. The auto width on the anchor lets each
     panel shrink for VS chars and stretch for unit chars. */
  height: 360px;
  width: auto;
  border-radius: 4px;
  overflow: hidden;
  isolation: isolate;
  text-decoration: none;
  color: inherit;
  background: var(--surface-2);
  box-shadow: var(--shadow-sm);
  transition: transform .18s ease, box-shadow .18s ease;
}
.char-panel:hover {
  transform: translateY(-4px);
  box-shadow: 0 14px 28px color-mix(in srgb, var(--stripe) 30%, transparent),
              0 4px 10px rgba(0,0,0,0.15);
}
/* Faint unit-coloured backplate band behind the asset — gives the panel
   a little tinted personality where the asset is transparent (it isn't
   currently, but this is cheap insurance against future asset edits). */
.char-panel-band {
  position: absolute;
  inset: 0;
  z-index: 1;
  background:
    linear-gradient(180deg,
      color-mix(in srgb, var(--stripe) 18%, transparent) 0%,
      color-mix(in srgb, var(--stripe) 32%, transparent) 100%);
}
/* Vertical character-name watermark — kept as a faint accessibility hint
   for screen-reader / right-edge text users. The baked-in asset watermark
   is the primary one; this is an HTML-text fallback. */
.char-panel-watermark {
  position: absolute;
  top: 12px; right: 4px; bottom: 28%;
  z-index: 2;
  writing-mode: vertical-rl;
  text-orientation: upright;
  letter-spacing: 0.18em;
  font-weight: 900;
  font-size: 11px;
  color: rgba(255,255,255,0.0); /* visually hidden — asset already has it */
  pointer-events: none;
  overflow: hidden;
  white-space: nowrap;
}
/* The asset itself. Height-pinned, width auto so the natural aspect of
   the chr_tl_{id} image drives the panel width — VS narrower than unit. */
.char-panel-art {
  position: relative;
  z-index: 3;
  height: 100%;
  display: block;
  overflow: hidden;
}
/* Asset-only selector (was `.char-panel-art img`). Using a dedicated class
   instead of a descendant selector so the corner logo image (a different
   <img> that also lives inside .char-panel-art via the overlay) doesn't
   inherit these height-100% / width-auto rules — which previously made
   the logo render full-panel-tall and overflow its corner bbox. */
.char-panel-art-asset {
  display: block;
  height: 100%;
  width: auto;
  object-fit: contain;
  object-position: center top;
}
/* Overlay pinned to the bottom portion of the asset: a black dashed line
   at top:60% and a filled unit logo at 40% opacity in the bottom-right.
   The dash + corner-logo together imitate the in-game treatment where
   the unit's logo sits beside the name plate baked into the artwork. */
.char-panel-overlay {
  position: absolute;
  inset: 0;
  z-index: 4;
  pointer-events: none;
}
.char-panel-dash {
  position: absolute;
  /* Pulled in from 8% → 20% on both sides so the dash sits under the
     baked-in nameplate rather than running across the asset's torso, and
     dropped from top:60% → 85% so it lands right above the corner logo. */
  left: 20%; right: 20%;
  top: 85%;
  height: 1px;
  background: repeating-linear-gradient(90deg,
    rgba(0,0,0,0.78) 0 4px,
    transparent 4px 8px);
}
.char-panel-corner-logo {
  position: absolute;
  right: 6px;
  bottom: 6px;
  height: 22px;
  width: auto;
  max-width: 60%;
  object-fit: contain;
  opacity: 0.4;
  filter: drop-shadow(0 1px 1px rgba(0,0,0,0.25));
}
/* VS panels: the VIRTUAL SINGER wordmark is wider than the unit roundels,
   so give it a touch more room and slightly less opacity to feel even. */
.char-panel.is-vs .char-panel-corner-logo {
  height: 14px;
  max-width: 70%;
  opacity: 0.45;
}
/* If chr_tl_{id} or the unit logo 404 the imgTag onerror hides them; the
   broken-image classes from app.js take care of the visual. We rely on
   the unit-tinted band underneath so the panel still looks intentional. */
.char-panel .img-broken { display: none; }

/* Responsive: shrink panel height on narrow viewports so the grid still
   wraps cleanly on phones. */
@media (max-width: 520px) {
  .char-panel-grid { gap: 10px; }
  .char-panel { height: 260px; }
  .char-panel-corner-logo { height: 18px; }
  .char-panel.is-vs .char-panel-corner-logo { height: 11px; }
}

/* ========== C31 Character subpage hero ================================== */
.char-hero {
  /* C36: dropped the right-hand .char-hero-plate text block. The chr_tl_{id}
     asset already bakes in the in-game JP/EN name plate, so duplicating it
     in HTML was redundant — and the carousel scrim that existed to keep
     that duplicate plate legible is gone too. New layout (all scoped to
     the 220px art column on the left so the carousel band stays the
     dominant visual element):
       * .char-hero-band                — absolute fill, holds the carousel
       * .char-hero-art                 — 220px portrait on the left
         └ .char-hero-art-asset        — chr_tl_{id} standee
         └ .char-hero-overlay          — corner unit logo, art-relative
         └ .char-hero-cv-strip         — solid unit-tinted strip pinned to
                                          the bottom of the ART (not the
                                          full hero), so the strip width
                                          matches the portrait width.
     The CV strip ALWAYS renders (even with empty CV — e.g. Miku) so the
     art block reads consistently across the cast. */
  position: relative;
  padding: 18px;
  margin: 8px 0 20px;
  border-radius: var(--radius-md);
  border: 1px solid var(--line);
  background: var(--surface);
  overflow: hidden;
  isolation: isolate;
}
.char-hero-band {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  background:
    linear-gradient(110deg,
      color-mix(in srgb, var(--stripe) 22%, transparent) 0%,
      color-mix(in srgb, var(--stripe) 6%, transparent) 60%,
      transparent 100%);
}
/* The two carousel crossfade layers. .is-active is opaque, .is-next is
   fully transparent; the JS module swaps these classes on the layer pair
   every ROTATE_MS and the `transition: opacity` declared inline (set in
   startCarousel()) animates the fade. With the name-plate text gone we no
   longer need a darkening scrim on top of the carousel — the chr_tl asset
   has enough visual weight to read against the rotating card art. */
.char-hero-band-art {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 1;
  object-fit: cover;
  object-position: center 30%;
}
.char-hero-band-art.is-active { opacity: 1; }
.char-hero-band-art.is-next   { opacity: 0; }
/* The chr_tl_{id} portrait asset is natively ~3:8 (240x840 for VS chars,
   376x840 for unit chars) — we let the asset dictate the box height
   instead of forcing an aspect-ratio crop so the full standee shows
   without cutting feet or head. Stays on the LEFT (per user spec) so the
   carousel remains the dominant visual element. Padding-bottom reserves
   vertical space for the CV strip pinned at the bottom. */
.char-hero-art {
  position: relative;
  z-index: 1;
  width: 220px;
  padding-bottom: var(--char-hero-cv-strip-h);
  border-radius: var(--radius-sm);
  overflow: hidden;
  background: color-mix(in srgb, var(--stripe) 30%, var(--surface-2));
  box-shadow: var(--shadow-md);
  --char-hero-cv-strip-h: 36px;
}
/* Use a dedicated class (not a descendant selector) so the corner-logo
   img inside .char-hero-overlay doesn't inherit width:100%/height:auto —
   same pattern used by .char-panel-art-asset on the selector page. */
.char-hero-art-asset {
  display: block;
  width: 100%;
  height: auto;
}
/* Overlay pinned to the art, holding only the corner unit logo at low
   opacity. Mirrors .char-panel-overlay on the selector grid; the dashed
   line treatment from the panel is NOT copied here — the hero uses the
   solid CV strip below instead. */
.char-hero-overlay {
  position: absolute;
  inset: 0;
  z-index: 4;
  pointer-events: none;
}
/* Bottom offset = strip height + 8px gap so the logo sits ABOVE the CV
   strip rather than hiding behind it. Using calc() against the same
   --char-hero-cv-strip-h custom prop keeps desktop / phone in sync. */
.char-hero-corner-logo {
  position: absolute;
  right: 8px;
  bottom: calc(var(--char-hero-cv-strip-h) + 8px);
  height: 26px;
  width: auto;
  max-width: 60%;
  object-fit: contain;
  opacity: 0.4;
  filter: drop-shadow(0 1px 1px rgba(0,0,0,0.25));
}
/* VS hero: VIRTUAL SINGER wordmark is wider than the unit roundels so
   give it a touch more room and slightly less opacity, matching the
   .char-panel.is-vs treatment for visual consistency. */
.char-hero.is-vs .char-hero-corner-logo {
  height: 16px;
  max-width: 70%;
  opacity: 0.45;
}
/* Solid unit-tinted strip pinned to the bottom of .char-hero-art (NOT the
   full hero) — matches the portrait's 220px width and visually grounds
   the standee. Renders unconditionally (empty .char-hero-cv-name for
   characters without a profile) so the art block footprint is stable
   across the cast. White text with a subtle shadow ensures legibility on
   the brighter unit colors (idol leaf green, theme_park goldenrod,
   piapro cyan). */
.char-hero-cv-strip {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 5;
  height: var(--char-hero-cv-strip-h);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 0 10px;
  background: var(--stripe);
  color: #fff;
  text-shadow: 0 1px 2px rgba(0,0,0,0.35);
}
.char-hero-cv-label {
  font-weight: 800;
  font-size: 12px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  /* Slightly translucent so the value reads as primary. */
  opacity: 0.85;
}
.char-hero-cv-name {
  font-weight: 600;
  font-size: 14px;
  color: #fff;
  /* Empty CV: collapses to zero width but the strip + label still show. */
}
.char-section { margin: 28px 0; }
.char-section h2 { display: flex; align-items: center; gap: 12px; }
.char-section h2 .tag { font-size: 13px; }

/* Phone layout — narrower art, slightly shorter strip. */
@media (max-width: 640px) {
  .char-hero { padding: 12px; }
  .char-hero-art { width: 160px; --char-hero-cv-strip-h: 30px; }
  .char-hero-corner-logo {
    height: 20px;
    right: 6px;
    bottom: calc(var(--char-hero-cv-strip-h) + 6px);
  }
  .char-hero.is-vs .char-hero-corner-logo { height: 12px; }
  .char-hero-cv-label { font-size: 11px; }
  .char-hero-cv-name  { font-size: 12px; }
}

/* ---------- Music card (with real jacket) ---------- */
.music-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 18px;
}
.music-card {
  display: flex; flex-direction: column;
  background: var(--surface);
  border-radius: var(--radius-md);
  border: 1px solid var(--line);
  overflow: hidden;
  box-shadow: var(--shadow-sm);
  transition: transform .15s ease, box-shadow .15s ease;
}
.music-card:hover { transform: translateY(-4px); box-shadow: var(--shadow-md); border-color: var(--line-strong); }
.music-card-art {
  aspect-ratio: 1;
  background: var(--surface-2);
  overflow: hidden;
}
.music-card-art img { width: 100%; height: 100%; object-fit: cover; display: block; }
.music-card-body { padding: 12px 14px 14px; }
.music-card-body h3 { font-size: 14px; line-height: 1.3; margin: 0 0 4px;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.music-card-tags { margin-top: 6px; }

/* ---------- Music detail ---------- */
.music-detail {
  display: grid;
  grid-template-columns: 280px 1fr;
  gap: 32px;
  align-items: start;
}
.music-detail-art {
  aspect-ratio: 1;
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: var(--surface-2);
  box-shadow: var(--shadow-lg);
  border: 1px solid var(--line);
}
.music-detail-art img { width: 100%; height: 100%; object-fit: cover; display: block; }
@media (max-width: 720px) {
  .music-detail { grid-template-columns: 1fr; }
  .music-detail-art { width: 100%; max-width: 320px; }
}

/* ---------- C4: music → event(s) cross-link block ----------
 * Sits between the categories row and the Difficulties heading on
 * /music/:slug. Hidden entirely when the song has no event ties (the
 * handler emits an empty string — no wrapper rendered).
 * The row uses thumbnail-sized event banners so multiple ties (world
 * bloom medleys) wrap gracefully. Each tile links to #/event-story/:id.
 */
.music-event-links { margin-top: 20px; }
.music-event-links-label {
  font-size: 11px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
  font-weight: 700;
  margin-bottom: 8px;
}
.music-event-links-row {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}
.music-event-link {
  display: flex;
  flex-direction: column;
  width: 200px;
  text-decoration: none;
  color: inherit;
  border-radius: 12px;
  overflow: hidden;
  background: var(--surface-2);
  border: 1px solid var(--line);
  box-shadow: var(--shadow-sm);
  transition: transform 0.12s ease, box-shadow 0.12s ease, border-color 0.12s ease;
}
.music-event-link:hover {
  transform: translateY(-1px);
  box-shadow: var(--shadow-md);
  border-color: var(--accent, var(--line));
}
.music-event-link-art {
  aspect-ratio: 488 / 208;
  background: var(--surface-3, var(--surface-2));
  overflow: hidden;
}
.music-event-link-art img {
  width: 100%; height: 100%; object-fit: cover; display: block;
}
.music-event-link-title {
  padding: 8px 10px;
  font-size: 13px;
  font-weight: 600;
  line-height: 1.3;
  /* Two-line clamp so longer event names don't push tile heights uneven. */
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* ---------- Difficulty chips (PJSK-style coloured badges) ---------- */
.diff-row { display: flex; flex-wrap: wrap; gap: 10px; }
.diff-chip {
  border-radius: 14px;
  padding: 12px 18px;
  color: white;
  min-width: 110px;
  box-shadow: var(--shadow-sm);
  font-weight: 700;
  background: linear-gradient(135deg, var(--diff-c1, #888), var(--diff-c2, #555));
}
.diff-chip-label { font-size: 11px; letter-spacing: 0.12em; opacity: 0.85; }
.diff-chip-level { font-family: var(--font-display); font-size: 28px; font-weight: 900; line-height: 1; margin: 4px 0; }
.diff-chip-notes { font-size: 11px; opacity: 0.85; font-weight: 600; }
.diff-chip.diff-easy    { --diff-c1: #74d6c0; --diff-c2: #34b8a0; }
.diff-chip.diff-normal  { --diff-c1: #6dc5ff; --diff-c2: #3a8fe6; }
.diff-chip.diff-hard    { --diff-c1: #ffd55a; --diff-c2: #ff9a23; }
.diff-chip.diff-expert  { --diff-c1: #ff7790; --diff-c2: #ee2255; }
.diff-chip.diff-master  { --diff-c1: #c280ff; --diff-c2: #7a3ed1; }
.diff-chip.diff-append  { --diff-c1: #ffb6e6; --diff-c2: #ff5fb1; }

/* ---------- responsive ---------- */

/* ---------- responsive ---------- */
@media (max-width: 980px) {
  body { grid-template-columns: 1fr; }
  #nav {
    position: relative;
    height: auto;
    flex-direction: row;
    flex-wrap: wrap;
    padding: 14px;
    gap: 10px;
  }
  #nav nav { flex-direction: row; flex-wrap: wrap; flex: 1 1 100%; gap: 4px; }
  #nav nav a { padding: 8px 12px; font-size: 13px; }
  #nav nav a .nico { width: 22px; height: 22px; }
  /* C18: keep the footer visible on all widths because it now carries the
   * build-version stamp. Span the full width when the strip wraps. */
  #nav footer { width: 100%; max-width: none; }
  #app { padding: 18px; }
  .asset-viewer { grid-template-columns: 1fr; }
  .reader-stage { aspect-ratio: 4/3; }
  .hero { padding: 24px 22px; }
  .hero h1 { font-size: 26px; }
}

/* ============================================================================
   JP Lookup popup (Phase: jp-lookup step 6). Anchored hover/tap dictionary
   surface — small card with headword, reading, POS, glosses, paginator, and
   Jisho/Weblio external-search buttons. Positioning is set inline by popup.js
   (left/top via getBoundingClientRect); CSS controls only look + feel.
   ============================================================================ */
.jp-popup {
  /* `position: fixed` is set inline; we keep z-index high so the popup
     floats above the dialogue overlay (z:3) and any stage portrait (z:2). */
  z-index: 50;
  min-width: 220px;
  max-width: 360px;
  background: var(--surface, #fff);
  color: var(--ink, #1a2240);
  border: 1px solid var(--line-strong, #cfd6ef);
  border-radius: var(--radius, 14px);
  box-shadow: var(--shadow-lg, 0 18px 38px rgba(34,44,90,.14), 0 6px 12px rgba(34,44,90,.08));
  padding: 12px 14px;
  font-family: var(--font-ui, system-ui, sans-serif);
  font-size: 14px;
  line-height: 1.45;
  /* Override the dialogue-overlay's pointer-events: none so popup buttons
     are actually clickable when popup is appended into the stage tree. */
  pointer-events: auto;
  /* Flex column so head + foot stay sized to content and body can take the
     remaining space and become scrollable when content overflows. The actual
     max-height is set inline by position() based on viewport room. The gap
     replaces the old body margin-bottom so head/body/foot stay visually
     separated whether or not the body is scrolling. */
  display: flex;
  flex-direction: column;
  gap: 2px;
  /* Hard fallback so a runaway entry can never paint outside the viewport
     even before position() runs (initial measure pass uses default size). */
  max-height: calc(100vh - 24px);
  /* When the body overflows, the rounded corners of the body's scrollbar
     should clip cleanly to the popup's border-radius. */
  overflow: hidden;
}
.jp-popup-body {
  /* In a popup-overflow scenario, the body scrolls independently while head
     and foot remain visible — the standard flex-scroll trick (min-height:0
     lets a flex item shrink below its content size). */
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  /* Smooth touch scrolling on iOS. */
  -webkit-overflow-scrolling: touch;
  /* Slim scrollbar that doesn't fight the popup chrome. */
  scrollbar-width: thin;
  scrollbar-color: var(--line-strong, #cfd6ef) transparent;
}
.jp-popup-body::-webkit-scrollbar { width: 6px; }
.jp-popup-body::-webkit-scrollbar-thumb {
  background: var(--line-strong, #cfd6ef);
  border-radius: 3px;
}
.jp-popup-head, .jp-popup-foot {
  /* Pin the chrome — don't shrink when the body needs room. */
  flex: 0 0 auto;
}
.jp-popup-head {
  display: flex;
  flex-direction: column;
  gap: 2px;
  margin-bottom: 6px;
  border-bottom: 1px solid var(--line, #e2e8f5);
  padding-bottom: 6px;
}
.jp-popup-headword {
  font-family: "Zen Maru Gothic", var(--font-display, sans-serif);
  font-weight: 700;
  font-size: 20px;
  color: var(--ink, #1a2240);
}
.jp-popup-reading {
  font-size: 13px;
  color: var(--ink-soft, #58608a);
}
.jp-popup-reasons:empty { display: none; }
.jp-popup-reasons {
  font-size: 11px;
  color: var(--muted, #8b94bb);
  font-style: italic;
}
.jp-popup-body {
  /* Stack senses vertically with a small gap. (Scroll/flex sizing for
     overflow is set on the earlier .jp-popup-body block.) */
  display: flex;
  flex-direction: column;
  gap: 6px;
  /* No margin-bottom — the popup root's flex layout handles spacing now;
     a bottom margin here would create dead space between scrollable body
     and the pinned foot, making the foot appear detached. */
}
.jp-popup-sense {
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.jp-popup-pos {
  font-size: 11px;
  color: var(--accent-violet, #884cc2);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
.jp-popup-gloss { color: var(--ink, #1a2240); }
.jp-popup-foot {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 8px;
  border-top: 1px solid var(--line, #e2e8f5);
  padding-top: 6px;
}
.jp-popup-pager { display: inline-flex; align-items: center; gap: 6px; }
.jp-popup-pager button {
  width: 24px; height: 24px;
  border-radius: var(--radius-pill, 999px);
  border: 1px solid var(--line-strong, #cfd6ef);
  background: var(--surface-2, #f7f9ff);
  color: var(--ink, #1a2240);
  font-size: 14px;
  line-height: 1;
  cursor: pointer;
  padding: 0;
}
.jp-popup-pager button:disabled {
  opacity: 0.4;
  cursor: default;
}
.jp-popup-page-label {
  font-size: 11px;
  color: var(--muted, #8b94bb);
  min-width: 32px;
  text-align: center;
}
.jp-popup-ext { display: inline-flex; gap: 6px; }
.jp-popup-ext button {
  border-radius: var(--radius-pill, 999px);
  border: 1px solid var(--line-strong, #cfd6ef);
  background: #fff;
  padding: 3px 10px;
  font-size: 11px;
  font-weight: 700;
  color: var(--ink-soft, #58608a);
  cursor: pointer;
}
.jp-popup-ext button:hover { background: var(--surface-2, #f7f9ff); }

/* JP-lookup EDRDG attribution. Small italic credit under the reader-audio
   bar; CC BY-SA 4.0 license requires visible attribution while the data
   is in use. Anchors inherit the page link color but keep a subtle hover. */
.jp-attribution {
  margin-top: 8px;
  font-size: 11px;
  color: var(--muted, #8b94bb);
  font-style: italic;
  line-height: 1.4;
}
.jp-attribution a {
  color: inherit;
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: 2px;
}
.jp-attribution a:hover { color: var(--ink-soft, #58608a); }

/* Visual affordance for lookable JP tokens. We use a dotted underline that
   shows up only on hover (so the line of text doesn't look like a sea of
   underlines while reading). On touch devices `:hover` is sticky, so the
   hint only appears mid-long-press — acceptable. */
[data-jp-lookable] {
  cursor: help;
  /* Use text-decoration-* triplet so it doesn't clash with the inherited
     text-decoration on the parent .line. */
  text-decoration: underline dotted transparent;
  text-decoration-thickness: 1px;
  text-underline-offset: 4px;
  transition: text-decoration-color 120ms ease;
}
[data-jp-lookable]:hover {
  text-decoration-color: rgba(255, 255, 255, 0.85);
}

/* Furigana display modes. The reader root carries .furigana-off /
   .furigana-hover / .furigana-always; <rt> elements (kana readings above
   kanji) follow the chosen mode. We use display:none for off so the line
   keeps its native height instead of reserving ruby space. For hover mode
   we keep the rt in the layout but transparent — so it doesn't reflow when
   it appears — and fade it in on hover or popup-open. */
.furigana-off rt { display: none; }
.furigana-always rt {
  visibility: visible;
  font-size: 0.55em;
  color: rgba(255, 255, 255, 0.85);
  font-weight: 400;
  letter-spacing: 0;
  user-select: none;
}
.furigana-hover rt {
  visibility: hidden;
  font-size: 0.55em;
  color: rgba(255, 255, 255, 0.85);
  font-weight: 400;
  letter-spacing: 0;
  user-select: none;
  transition: visibility 0s linear 80ms;
}
.furigana-hover [data-jp-token]:hover rt {
  visibility: visible;
  transition: visibility 0s linear 0s;
}

/* Furigana toggle UI: 3-button segmented control matching the muted/auto
   row visually but compact. Active button uses the accent gradient. */
.furigana-toggle {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  color: var(--ink-soft, #58608a);
}
.furigana-toggle .label { user-select: none; }
.furigana-toggle .seg {
  display: inline-flex;
  border: 1px solid var(--line, #d6dcee);
  border-radius: 6px;
  overflow: hidden;
}
.furigana-toggle .seg button {
  background: transparent;
  border: 0;
  padding: 4px 10px;
  font: inherit;
  color: var(--ink-soft, #58608a);
  cursor: pointer;
  border-right: 1px solid var(--line, #d6dcee);
  min-width: 52px;
}
.furigana-toggle .seg button:last-child { border-right: 0; }
.furigana-toggle .seg button:hover { background: var(--surface-2, #f7f9ff); }
.furigana-toggle .seg button.is-active {
  background: linear-gradient(135deg, #b8caff, #d6b8ff);
  color: #1a1a2e;
  font-weight: 600;
}

/* ============================================================================
   Reader landscape height cap (Phase: mobile QoL).

   When a phone is rotated to landscape, the default 16:9 aspect-ratio on
   .reader-stage drives height from width — and on a typical Android phone
   (e.g. 915 × 412) that yields a stage ~514px tall in a 412px-tall viewport,
   so the bottom of the dialogue overlay is clipped and the user has to
   scroll. We cap height to the dynamic viewport and let width derive from
   the 16:9 aspect-ratio (auto), which keeps the stage centered with
   letterbox padding on the sides instead of vertical clipping.

   Targets short *landscape* viewports only — desktop "landscape" windows
   are always tall enough that the 16:9 stage fits naturally. The 600px
   threshold matches common rotated-phone heights without catching laptops.
   ============================================================================ */
@media (orientation: landscape) and (max-height: 600px) {
  .reader-stage:not(:fullscreen):not(:-webkit-full-screen):not(.is-fake-fullscreen) {
    /* Switch height-driven sizing: derive width from height × 16/9 instead
       of height from width. */
    height: 100dvh;
    max-height: 100dvh;
    width: auto;
    max-width: 100%;
    margin-left: auto;
    margin-right: auto;
  }
  /* Let the reader container shed its 940px cap so the height-driven stage
     can centre itself across the full landscape viewport. */
  .reader { max-width: none; }
}

/* ============================================================================
   Reader fullscreen mode (Phase: mobile QoL).

   Two paths:
     1. Native Fullscreen API     → .reader-stage:fullscreen / :-webkit-full-screen
     2. CSS-only fallback         → .reader-stage.is-fake-fullscreen
                                    (paired with body.reader-fake-fullscreen)

   Both paths drop the 16:9 aspect-ratio so the stage fills the device's
   real aspect (portrait phone, tablet, etc.). Background art uses `cover`
   so it crops gracefully; the dialogue overlay stays anchored to the
   bottom because it was always positioned that way. The fake-fullscreen
   path also locks body scroll + raises z-index above #nav so the stage
   truly covers the viewport.
   ============================================================================ */
.reader-stage:fullscreen,
.reader-stage:-webkit-full-screen,
.reader-stage.is-fake-fullscreen {
  aspect-ratio: auto;
  width: 100vw;
  height: 100vh;
  /* Use dynamic viewport units when available so iOS Safari's URL bar
     doesn't clip the bottom of the dialogue overlay. */
  height: 100dvh;
  max-width: none;
  max-height: none;
  border-radius: 0;
  border: 0;
  box-shadow: none;
}

/* Native Fullscreen API: the browser creates a top-layer host, so we only
   need to remove the aspect-ratio constraint. The CSS-only fallback needs
   position:fixed to overlay the viewport. */
.reader-stage.is-fake-fullscreen {
  position: fixed;
  inset: 0;
  z-index: 1000;
}
body.reader-fake-fullscreen {
  overflow: hidden;
}

/* Fullscreen toggle button. Lives inside #stage as a child element so the
   Fullscreen API's top-layer host (which only displays descendants of the
   element it was called on) keeps it visible. Floats in the top-right
   corner as a discreet "chip" so it doesn't fight with the dialogue or
   the portrait. The same styling applies in normal + fullscreen states
   for consistency; only the safe-area insets are stronger in fullscreen. */
.reader-stage .stage-fs-btn {
  position: absolute;
  top: 10px;
  right: 10px;
  z-index: 4;
  padding: 6px 12px;
  font-size: 12px;
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.55);
  color: #fff;
  border: 1px solid rgba(255, 255, 255, 0.3);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  cursor: pointer;
  font-weight: 600;
  letter-spacing: 0.01em;
  /* Make sure taps register even if a transparent overlay is on top. */
  pointer-events: auto;
}
.reader-stage .stage-fs-btn:hover {
  background: rgba(0, 0, 0, 0.75);
}
/* In real or fake fullscreen mode, honour the device's safe-area insets
   so notch / status-bar regions don't cover the button. */
:fullscreen .stage-fs-btn,
:-webkit-full-screen .stage-fs-btn,
.reader-stage.is-fake-fullscreen .stage-fs-btn {
  top: max(10px, env(safe-area-inset-top));
  right: max(10px, env(safe-area-inset-right));
}

/* ============================================================================
 * Stage settings drawer (Phase: mobile-fullscreen QoL).
 *
 * A gear chip that floats next to the fullscreen toggle, and a drop-down
 * panel that mirrors the most-used controls from the audio bar below the
 * reader (mute, auto-voice, volume, font size, furigana, secondary language).
 *
 * Why duplicate the controls instead of moving them? In native fullscreen
 * mode the OS top-layer only displays descendants of the element passed to
 * requestFullscreen — that's #stage. The audio bar lives *outside* #stage
 * (it sits in .reader-audio below the controls), so it's hidden whenever the
 * stage is fullscreened. Reparenting the whole bar is brittle (it tangles
 * with grid layout, accessibility focus order, and the existing wiring),
 * so we mount a second copy of the inputs *inside* #stage and keep both
 * sides in sync — see reader/index.js. The fs-* inputs have the same
 * controls; just different ids prefixed `fs-`.
 *
 * Visual style mirrors .stage-fs-btn (translucent-black chip, white text,
 * backdrop blur) so the two stage chips feel like a pair.
 * ========================================================================= */
.reader-stage .stage-settings-btn {
  position: absolute;
  top: 10px;
  right: 130px;
  z-index: 4;
  padding: 6px 12px;
  font-size: 12px;
  border-radius: 999px;
  background: rgba(0, 0, 0, 0.55);
  color: #fff;
  border: 1px solid rgba(255, 255, 255, 0.3);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  cursor: pointer;
  font-weight: 600;
  letter-spacing: 0.01em;
  pointer-events: auto;
}
.reader-stage .stage-settings-btn:hover {
  background: rgba(0, 0, 0, 0.75);
}
/* In real/fake fullscreen, respect safe-area insets so the chip pair
   isn't covered by a notch. The settings chip sits to the LEFT of the
   fullscreen chip; their gap is calculated from the fs chip's known
   width (~120px including padding) to keep them visually paired. */
:fullscreen .stage-settings-btn,
:-webkit-full-screen .stage-settings-btn,
.reader-stage.is-fake-fullscreen .stage-settings-btn {
  top: max(10px, env(safe-area-inset-top));
  right: calc(max(10px, env(safe-area-inset-right)) + 120px);
}

/* The drawer itself — anchored under the gear chip, top-right of the stage.
   White-on-translucent-black is the user's stated preference. Hidden by
   default; toggled via the [hidden] attribute from JS. Max-height keeps
   it from running off short viewports; the body scrolls instead. */
.reader-stage .stage-settings-panel {
  position: absolute;
  top: 44px;
  right: 10px;
  z-index: 5;
  min-width: 260px;
  max-width: min(340px, calc(100vw - 20px));
  max-height: calc(100% - 60px);
  display: flex;
  flex-direction: column;
  background: rgba(0, 0, 0, 0.72);
  color: #fff;
  border: 1px solid rgba(255, 255, 255, 0.25);
  border-radius: 12px;
  backdrop-filter: blur(10px);
  -webkit-backdrop-filter: blur(10px);
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.35);
  pointer-events: auto;
  overflow: hidden;   /* round outer corners; body handles scroll */
}
.reader-stage .stage-settings-panel[hidden] { display: none; }
:fullscreen .stage-settings-panel,
:-webkit-full-screen .stage-settings-panel,
.reader-stage.is-fake-fullscreen .stage-settings-panel {
  top: calc(max(10px, env(safe-area-inset-top)) + 34px);
  right: max(10px, env(safe-area-inset-right));
}

.stage-settings-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 12px;
  border-bottom: 1px solid rgba(255, 255, 255, 0.15);
  flex: 0 0 auto;
}
.stage-settings-title {
  font-weight: 700;
  font-size: 13px;
  letter-spacing: 0.02em;
}
.stage-settings-close {
  background: transparent;
  color: #fff;
  border: none;
  font-size: 20px;
  line-height: 1;
  cursor: pointer;
  padding: 0 4px;
  opacity: 0.8;
}
.stage-settings-close:hover { opacity: 1; }

.stage-settings-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  padding: 10px 12px 12px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  font-size: 13px;
}
.stage-settings-row {
  display: flex;
  align-items: center;
  gap: 8px;
  color: #fff;
  cursor: pointer;
}
.stage-settings-row.stage-settings-slider,
.stage-settings-row.stage-settings-group {
  flex-direction: column;
  align-items: stretch;
  gap: 6px;
  cursor: default;
}
.stage-settings-label {
  font-size: 12px;
  opacity: 0.85;
  display: flex;
  justify-content: space-between;
}
.stage-settings-value {
  opacity: 0.7;
  font-variant-numeric: tabular-nums;
}
.stage-settings-row input[type="range"] {
  width: 100%;
  accent-color: #4dd1ff;
}
.stage-settings-row input[type="checkbox"] {
  accent-color: #4dd1ff;
}
.stage-settings-row select {
  width: 100%;
  padding: 6px 8px;
  background: rgba(255, 255, 255, 0.08);
  color: #fff;
  border: 1px solid rgba(255, 255, 255, 0.25);
  border-radius: 6px;
  font-size: 13px;
}
.stage-settings-row select option {
  /* Native dropdown menu defaults to system colors; force readable text
     in case the browser keeps the dark background. */
  background: #1a1a1a;
  color: #fff;
}
.stage-settings-row .seg {
  display: inline-flex;
  gap: 0;
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 8px;
  overflow: hidden;
}
.stage-settings-row .seg button {
  flex: 1 1 0;
  padding: 6px 10px;
  background: transparent;
  color: #fff;
  border: none;
  border-left: 1px solid rgba(255, 255, 255, 0.18);
  cursor: pointer;
  font-size: 12px;
}
.stage-settings-row .seg button:first-child { border-left: none; }
.stage-settings-row .seg button.is-active {
  background: rgba(77, 209, 255, 0.28);
  font-weight: 700;
}

/* ============================================================================
 * Skeleton placeholders -- shown while home/page data is loading.
 *
 * Each .skel-<name> matches the rough shape and size of the real content it
 * replaces, so the layout doesn't shift when the real DOM is swapped in. The
 * shimmer is a single keyframe applied via CSS variables on a linear-gradient
 * background-image, kept cheap so it doesn't cost mobile CPU.
 * ========================================================================= */
@keyframes pjsk-skel-shimmer {
  0%   { background-position: -200% 0; }
  100% { background-position:  200% 0; }
}
.skel {
  position: relative;
  display: inline-block;
  border-radius: 12px;
  background: linear-gradient(90deg, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.10) 50%, rgba(0,0,0,0.05) 100%);
  background-size: 200% 100%;
  animation: pjsk-skel-shimmer 1.4s ease-in-out infinite;
  flex: 0 0 auto;
}
@media (prefers-reduced-motion: reduce) {
  .skel { animation: none; }
}

/* Featured-banner skeleton: full-width hero block matching the real banner. */
.skel-featured-banner {
  display: block;
  width: 100%;
  height: 220px;
  margin-bottom: 18px;
}
@media (max-width: 700px) {
  .skel-featured-banner { height: 160px; }
}

/* One song-strip card skeleton (the parent .song-strip already lays them out
 * horizontally; we just size the placeholder to match a real item). */
.skel-song-strip {
  width: 140px;
  height: 180px;
  margin-right: 10px;
}
@media (max-width: 700px) {
  .skel-song-strip { width: 110px; height: 150px; }
}

/* One cast-bubble skeleton (circle + name line). */
.skel-cast-strip {
  width: 72px;
  height: 96px;
  margin-right: 8px;
  border-radius: 12px;
}

/* When a skel sits inside the real container, ensure container layout still
 * works (.song-strip and .cast-strip are flex/grid -- the .skel honours it
 * because we kept display: inline-block + flex: 0 0 auto). */

/* ───────────────────────── Reader tap effect ────────────────────────────
 * Visual feedback for stage taps. JS spawns a <div.tap-fx> containing
 * 3 rings + ~12 triangle confetti pieces at the click point; CSS does
 * all the actual animation. See public/js/reader/tap-effect.js for the
 * spawn logic and rationale.
 *
 * Layout: .tap-fx is a 0×0 anchor pinned at the click point with
 * absolute positioning. All children (.tap-fx-ring, .tap-fx-tri) center
 * on it via translate(-50%, -50%) and animate as transforms from there.
 *
 * No JS animation loop — once .is-active is added, CSS transitions run
 * to completion and the JS timer removes the node ~1.1s later.
 *
 * Reduced motion: respects prefers-reduced-motion by skipping the
 * confetti and shrinking the ring pulse to a single small fade.
 */

.tap-fx {
  position: absolute;
  width: 0;
  height: 0;
  pointer-events: none;
  /* Pin the anchor exactly at the click point. JS sets left/top in px. */
  z-index: 30;
  /* Sit above the stage backgrounds but below the dialogue bubble.
     .dialogue-overlay sits at z-index ~50 in app.css. */

  /* 3D context for confetti tumble. Each .tap-fx-tri child rotates
     around X/Y/Z; without perspective those rotations collapse to flat
     2D shear. 600px feels right for a ~70px throw radius — enough
     foreshortening to read as paper-in-3D-space without making the
     pieces look like they're inside a fishbowl. */
  perspective: 600px;
  transform-style: preserve-3d;
}

/* App-wide tap-effect overlay. A single fixed-position layer pinned to the
   viewport so the tap effect can fire on ANY click in the app — not just
   inside the reader stage — without interfering with the click target.
   `pointer-events: none` makes it transparent to hit-testing; the very
   high z-index keeps the effect visible even above modals/drawers. JS
   spawns .tap-fx children using viewport-relative (clientX, clientY)
   coordinates, which line up directly with the layer's content box. */
.tap-fx-layer {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 99999;
  overflow: hidden;
  /* Prevent any inherited filter/transform from creating a containing
     block that would shift our pinned children. */
  contain: layout style;
}

.tap-fx-ring {
  position: absolute;
  left: 0;
  top: 0;
  width: 18px;
  height: 18px;
  /* Center the ring on the anchor. */
  transform: translate(-50%, -50%) scale(0.2);
  border-radius: 50%;
  /* Stroke that matches the keyframe's start width. The teal hue is
     painted with a moderate alpha so the ring reads as a soft accent
     rather than a solid painted shape (user feedback: "more
     transparent"). No mix-blend-mode — blend modes desaturate edges and
     create a soft halo by design, which is the "blurry" look we want to
     avoid here. */
  border: 4px solid var(--tap-fx-ring-color, rgb(80, 200, 200));
  background: transparent;
  opacity: 0;
  /* Composite each ring on its own layer during the brief animation so
     the GPU doesn't repaint the stage on every frame. */
  will-change: transform, opacity;
}

/* Rings: a ripple that expands outward from the tap point. Each ring
   starts as a small dot and snaps out to a wider radius, fading as it
   travels. At any instant during the animation the stack reads as the
   reference screenshot — three concentric circles of different sizes,
   because each successive ring starts a beat later and so is "behind"
   the one before it.

   We use @keyframes so we can shape the opacity curve independently of
   the scale curve: ring is at full opacity through ~70% of the run
   (the readable "painted shape" beat from the reference image), then
   fades to 0 over the final 30%. The scale curve eases out fast in
   the first half and decelerates so the ring decelerates as it
   reaches its outer radius (matches the in-game ripple feel).

   Per-ring start-delay is tight (0 / 35 / 70 ms) — enough that the
   rings read as three distinct trailing ripples, not enough that they
   feel like three separate taps. Total run time per ring: 200 ms; the
   last ring finishes at 70 + 200 = 270 ms. Border-width animates from
   5px → 1px so the ring visibly thins at the edge instead of clipping
   off at full thickness.

   If you retune the animation duration / delay, also bump LIFETIME_MS
   in tap-effect.js (it must outlast the latest ring's animation end). */
@keyframes tap-fx-ring-ripple {
  /* Ring grows from a dot, holds peak opacity briefly, then fades out while
     still expanding — and the border thins from 4px to 1px so the ring
     visibly tapers as it reaches its outer radius (rather than popping off
     at full thickness).

     Tuning (vs. the earlier 200ms / 0.95 peak): rings are now lighter and
     quicker to match user feedback ("more transparent, smaller, faster").
     Peak alpha drops to 0.65 so the rings read as a soft accent rather
     than a painted shape; total run shortens to 140ms so the burst
     resolves before the user's next tap.

     Confetti is intentionally NOT retuned here — user feedback called it
     out as "fine". The hard-remove timer LIFETIME_MS in tap-effect.js is
     the confetti's wall clock (≥ 430ms), so shrinking rings cannot leak
     nodes. */
  0%   { opacity: 0;    border-width: 4px; transform: translate(-50%, -50%) scale(0.2); }
  18%  { opacity: 0.65; border-width: 2px; }
  60%  { opacity: 0.50; border-width: 1.5px; }
  100% { opacity: 0;    border-width: 1px; transform: translate(-50%, -50%) scale(var(--tap-fx-ring-scale, 2.2)); }
}

/* Per-ring final scales — shrunk from the earlier 1.7/2.6/3.6 stack so
   the burst fits visually inside the confetti spread (which itself
   tops out at distance ≤ 70px). Stagger tightened from 35/70 to
   20/40ms so the three rings still read as a rolling ripple while
   keeping the full sequence inside the 140ms keyframe budget. */
.tap-fx.is-active .tap-fx-ring--0 {
  --tap-fx-ring-scale: 1.3;
  animation: tap-fx-ring-ripple 140ms cubic-bezier(0.2, 0.85, 0.25, 1) 0ms forwards;
}
.tap-fx.is-active .tap-fx-ring--1 {
  --tap-fx-ring-scale: 2.0;
  animation: tap-fx-ring-ripple 140ms cubic-bezier(0.2, 0.85, 0.25, 1) 20ms forwards;
}
.tap-fx.is-active .tap-fx-ring--2 {
  --tap-fx-ring-scale: 2.7;
  animation: tap-fx-ring-ripple 140ms cubic-bezier(0.2, 0.85, 0.25, 1) 40ms forwards;
}

/* Triangle confetti. Drawn with the classic CSS border trick: a 0×0
   element with three transparent borders + one colored border becomes
   an upward-pointing triangle. The element is sized by --tap-fx-size
   (the triangle's bounding box edge length).

   Final position is encoded as translate(--tap-fx-dx, --tap-fx-dy) +
   rotation; CSS transitions from the origin (no transform) to that
   final transform when .is-active is added. */
.tap-fx-tri {
  position: absolute;
  left: 0;
  top: 0;
  width: 0;
  height: 0;
  border-style: solid;
  /* Triangle dimensions picked INDEPENDENTLY by JS so each piece of
     paper has its own aspect ratio (tall-skinny, short-fat, near-
     isosceles). The border-triangle trick:
        bottom border  = triangle height  (= --tap-fx-h)
        left/right     = triangle width/2 (= --tap-fx-w / 2)
     Falls back to the legacy --tap-fx-size on either axis when an
     older spawnTapEffect happens to be on the page (defensive only).
   */
  border-width: 0
                calc(var(--tap-fx-w, var(--tap-fx-size, 12px)) / 2)
                var(--tap-fx-h, var(--tap-fx-size, 12px))
                calc(var(--tap-fx-w, var(--tap-fx-size, 12px)) / 2);
  border-color: transparent transparent var(--tap-fx-color, #fff) transparent;
  /* 3D plate behavior: keep our own 3D space so child transforms tumble
     instead of flattening, and draw the back face too — when a piece
     tumbles past 90° it would otherwise vanish (the border-triangle
     trick has no painted back side, but with backface-visibility:visible
     the browser mirrors the front face for us). */
  transform-style: preserve-3d;
  backface-visibility: visible;
  /* Origin: centered on the anchor, no drift yet. Per-piece initial
     orientation in 3D (--tap-fx-rx0, --tap-fx-ry0) so every triangle
     starts facing a different direction — even before the throw, the
     burst reads as a cloud of independent paper plates, not a flat
     stencil. */
  transform: translate(-50%, -50%)
             rotateX(var(--tap-fx-rx0, 0deg))
             rotateY(var(--tap-fx-ry0, 0deg));
  opacity: 1;
  will-change: transform, opacity;
  /* Soft glow matches the in-game paper-confetti material (`mat_common`).
     Tints the surrounding pixels with the confetti color so each piece
     reads as glowing paper, not a flat shape. */
  filter: drop-shadow(0 0 4px var(--tap-fx-color, #fff));
}

.tap-fx.is-active .tap-fx-tri {
  /* Per-piece duration (--tap-fx-dur, set on each .tap-fx-tri by JS) so
     pieces settle at different moments instead of all freezing at the
     same instant. Default kept at 390ms for back-compat with any
     consumer that doesn't set the variable. */
  transition: transform var(--tap-fx-dur, 390ms) cubic-bezier(0.18, 0.7, 0.36, 1) var(--tap-fx-delay, 0ms),
              opacity var(--tap-fx-dur, 390ms) ease-out var(--tap-fx-delay, 0ms);
  /* Final transform encodes the full throw: drift to (dx, dy), tumble
     around X and Y (so the piece flips like real paper falling), and
     Z spin. The translate(-50%, -50%) centering survives because we
     re-apply it here. Transform order matters: translate first so the
     piece lands in the right place, then rotations tumble it around
     its own center. */
  transform: translate(calc(-50% + var(--tap-fx-dx, 0px)),
                       calc(-50% + var(--tap-fx-dy, 0px)))
             rotateX(var(--tap-fx-rx, 0deg))
             rotateY(var(--tap-fx-ry, 0deg))
             rotateZ(var(--tap-fx-rot, 0deg));
  opacity: 0;
}

/* Reduced-motion: small fade, no flying confetti. Some users get
   migraines from a flurry of moving particles; this keeps the visual
   feedback while removing the motion. */
@media (prefers-reduced-motion: reduce) {
  .tap-fx-tri { display: none; }
  .tap-fx.is-active .tap-fx-ring--0,
  .tap-fx.is-active .tap-fx-ring--1,
  .tap-fx.is-active .tap-fx-ring--2 {
    transition: opacity 200ms ease-out;
    transform: translate(-50%, -50%) scale(2);
    opacity: 0;
  }
  /* Swipe-trail glow dots: skip the radial expansion + glow on
     reduced-motion, just hide them. The companion confetti bursts
     are already hidden by the .tap-fx-tri rule above. */
  .swipe-trail-dot { display: none; }
}

/* Swipe-trail glow dot. One spawned at each "anchor" along a finger
   drag (see public/js/reader/swipe-trail.js). The CSS animation does
   all the work — JS only spawns + hard-removes after the matching
   TRAIL_FADE_MS (720ms) wall-clock.

   Why a radial-gradient background instead of box-shadow or a single
   solid + filter: drop-shadow? A radial gradient renders entirely on
   the GPU compositor when paired with `will-change`, no per-frame
   paint. Single-element fade + bloom in one shot, very cheap. */
.swipe-trail-dot {
  position: absolute;
  left: 0;
  top: 0;
  width: 22px;
  height: 22px;
  margin-left: -11px;   /* center on the (left, top) anchor */
  margin-top: -11px;
  pointer-events: none;
  border-radius: 50%;
  /* Teal-into-transparent matches the ring tint so the trail reads
     as the same visual language as the tap burst. */
  background: radial-gradient(circle at 50% 50%,
              rgba(140, 230, 230, 0.85) 0%,
              rgba(140, 230, 230, 0.55) 35%,
              rgba(140, 230, 230, 0)    72%);
  filter: blur(0.5px);
  opacity: 0;
  will-change: transform, opacity;
  /* 720 ms matches swipe-trail.js TRAIL_FADE_MS. The keyframe spikes
     opacity at 8% so the dot pops into view (mirroring a tap-fx
     ring's initial flash), then fades out while growing slightly
     so the trail looks like glowing breath rather than a static
     bead. forwards keeps the end state (opacity: 0) until the JS
     timeout hard-removes the node. */
  animation: swipe-trail-dot-fade 720ms ease-out forwards;
}

@keyframes swipe-trail-dot-fade {
  0%   { opacity: 0;    transform: scale(0.6); }
  8%   { opacity: 0.95; transform: scale(1.0); }
  60%  { opacity: 0.40; transform: scale(1.3); }
  100% { opacity: 0;    transform: scale(1.6); }
}

/* ============================================================================
   GLOBAL SEARCH — sidebar input + dedicated #/search page
   ----------------------------------------------------------------------------
   The input lives inside #appbar (top bar, mounted by installTopBarSearch).
   The results page renders
   inside #view like any other route, so the input persists across navigation.
   ============================================================================ */

.sr-only {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0);
  white-space: nowrap; border: 0;
}

.topbar-search {
  /* Default margin: zero. The C11 layout positions the search box via its
   * parent (#appbar .topbar-search override). Older mounts inside #nav can
   * re-apply margin locally if reintroduced. */
  position: relative;
  margin: 0;
}
/* C5d: the topbar pill now hosts two inputs (q + speaker) in a single
 * rounded surface separated by a vertical divider. The outer pill paints
 * the surface, border, and focus-within accent; the inner <span> fields
 * lay out as [icon | input] with no inner background. The legacy
 * single-input rules below this block still apply when .topbar-search-
 * cluster is NOT present (kept as a safety fallback). */
.topbar-search-cluster {
  display: flex;
  align-items: stretch;
  gap: 0;
  border-radius: var(--radius-pill);
  border: 1px solid var(--line);
  background: var(--surface-2);
  overflow: hidden;
  transition: border-color .12s ease, background .12s ease, box-shadow .12s ease;
}
.topbar-search-cluster:focus-within {
  background: var(--surface);
  border-color: var(--accent);
  box-shadow: 0 0 0 3px rgba(255, 95, 177, 0.18);
}
.topbar-search-cluster .topbar-search-field {
  display: flex;
  align-items: center;
  gap: 8px;
  flex: 1 1 auto;
  min-width: 0;
  padding: 0 12px;
}
/* Speaker field is the secondary facet — give it a narrower share of the
 * flex space so the free-text box stays the primary surface. clamp keeps it
 * legible on narrow viewports without ballooning on wide ones. */
.topbar-search-cluster .topbar-search-field--speaker {
  flex: 0 1 clamp(120px, 22%, 180px);
}
.topbar-search-cluster .topbar-search-field input {
  /* Reset the legacy padding/border/background. The pill surface lives on
   * the outer cluster; each inner input is just text on a transparent bg. */
  width: 100%;
  min-width: 0;
  padding: 10px 0;
  border: 0;
  background: transparent;
  color: var(--ink);
  font: inherit;
  font-size: 14px;
  outline: none;
}
.topbar-search-cluster .topbar-search-field input::placeholder { color: var(--muted); }
.topbar-search-cluster .topbar-search-field input::-webkit-search-cancel-button { -webkit-appearance: none; }
.topbar-search-cluster .topbar-search-icon {
  /* The cluster's flex layout positions icons inline, so override the
   * absolute-position rule from the legacy single-input variant below. */
  position: static;
  transform: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  color: var(--muted);
  font-size: 16px;
  pointer-events: none;
}
.topbar-search-cluster .topbar-search-icon--person {
  /* The person glyph is an inline 24×24 SVG (Material Symbols Outlined).
   * Constrain it to match the visual weight of the unicode magnifier in
   * the q-field. */
  width: 16px; height: 16px;
}
.topbar-search-cluster .topbar-search-icon--person svg {
  width: 100%; height: 100%; display: block;
}
.topbar-search-divider {
  /* Thin vertical line between the two facets. align-self stretch makes
   * it span the full pill height so it reads as a real separator rather
   * than a floating tick. */
  align-self: stretch;
  width: 1px;
  background: var(--line);
  flex-shrink: 0;
}
@media (max-width: 720px) {
  /* On narrow viewports the speaker facet hides so the free-text box gets
   * the full pill width. The URL still honors ?speaker= deep links — we
   * only hide the UI, not the behavior. */
  .topbar-search-cluster .topbar-search-field--speaker,
  .topbar-search-divider { display: none; }
}

/* ---------- legacy single-input topbar-search (fallback) ----------
 * Active only when .topbar-search-cluster is NOT applied. Kept so any
 * cached/older mount or non-cluster invocation still renders correctly. */
.topbar-search:not(.topbar-search-cluster) input {
  width: 100%;
  padding: 10px 12px 10px 34px;
  border-radius: var(--radius-pill);
  border: 1px solid var(--line);
  background: var(--surface-2);
  color: var(--ink);
  font: inherit;
  font-size: 14px;
  outline: none;
  transition: border-color .12s ease, background .12s ease, box-shadow .12s ease;
}
.topbar-search:not(.topbar-search-cluster) input::placeholder { color: var(--muted); }
.topbar-search:not(.topbar-search-cluster) input:focus {
  background: var(--surface);
  border-color: var(--accent);
  box-shadow: 0 0 0 3px rgba(255, 95, 177, 0.18);
}
.topbar-search:not(.topbar-search-cluster) .topbar-search-icon {
  position: absolute;
  left: 12px; top: 50%; transform: translateY(-50%);
  color: var(--muted);
  font-size: 16px;
  pointer-events: none;
}
/* Hide the native clear button in WebKit so the icon doesn't fight it. */
.topbar-search:not(.topbar-search-cluster) input::-webkit-search-cancel-button { -webkit-appearance: none; }

/* ---------- search results page ---------- */

.search-empty {
  max-width: 720px;
}
.search-examples {
  display: flex; flex-wrap: wrap; gap: 8px;
  margin-top: 18px;
}
.search-example-chip {
  display: inline-flex; align-items: center;
  padding: 8px 14px;
  border-radius: var(--radius-pill);
  background: var(--surface);
  border: 1px solid var(--line);
  color: var(--ink-soft);
  font-weight: 700;
  font-size: 14px;
  transition: background .12s ease, border-color .12s ease, color .12s ease, transform .12s ease;
}
.search-example-chip:hover {
  background: var(--surface-2);
  border-color: var(--accent);
  color: var(--ink);
  transform: translateY(-1px);
}

.search-status {
  color: var(--ink-soft);
  font-size: 14px;
  margin: 4px 0 14px;
}
.search-status strong { color: var(--ink); }
.search-status a { color: var(--accent); font-weight: 700; }
.search-status .search-meta-ms,
.search-status .search-meta-cap {
  color: var(--muted);
  font-size: 12px;
  margin-left: 4px;
}
.search-status-error { color: var(--accent); }

/* 202-while-building panel. Rendered when the server reports the index
   hasn't finished its initial build yet. We pair the headline with a small
   per-region progress list so the user can see the build is making
   forward progress rather than stuck. The skeleton rows below this panel
   are the same .skel-search-result cards used by the normal loading
   state, so the height stays steady across the building → ready
   transition. */
.search-status-building {
  /* Tone-match the regular loading state — neutral, not error red. */
  color: var(--ink-soft);
}
.search-building-progress {
  list-style: none;
  padding: 0;
  margin: 8px 0 0;
  display: flex;
  flex-wrap: wrap;
  gap: 6px 10px;
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}
.building-row {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 2px 8px;
  border-radius: 999px;
  background: var(--surface-2, rgba(0, 0, 0, 0.04));
  border: 1px solid var(--line);
  color: var(--muted);
}
.building-region {
  font-weight: 700;
  color: var(--ink);
}
.building-state {
  font-weight: 500;
}
/* State-specific tinting. Ready = accent (matches the rest of the app's
   success affordance); building = soft attention pulse; error = subdued
   red so it doesn't drown out the still-progressing regions. */
.building-row--ready  { border-color: var(--accent); color: var(--accent); }
.building-row--ready .building-region { color: var(--accent); }
.building-row--building { border-color: var(--line-strong); }
.building-row--error  { color: var(--accent); border-color: var(--accent); opacity: 0.7; }
.building-row--idle   { /* default styling */ }

.search-results {
  display: flex; flex-direction: column;
  gap: 10px;
}
.search-result-card {
  display: block;
  padding: 14px 16px;
  border-radius: var(--radius);
  background: var(--surface);
  border: 1px solid var(--line);
  box-shadow: var(--shadow-sm);
  transition: transform .12s ease, box-shadow .12s ease, border-color .12s ease;
  text-decoration: none;
  color: var(--ink);
}
/* When a result has a per-kind icon (event logo / card thumb / chibi), the
   card becomes a horizontal flex with the icon on the left and the existing
   meta+row stack on the right. Unit/special results render without an icon
   and keep the original block layout. */
.search-result-card.has-icon {
  display: flex;
  align-items: center;
  gap: 12px;
}
.search-result-icon {
  flex-shrink: 0;
  width: 56px;
  height: 56px;
  /* Slight bg so the chibi (transparent webp) sits on something on light
     and dark themes alike. Same surface-2 var that other thumbnails use. */
  background: var(--surface-2, rgba(0, 0, 0, 0.04));
  border-radius: 8px;
  overflow: hidden;
  /* The icon wrapper holds either a single <img> (single-character / event
     logo / card thumb) or a 2x2 grid of chibi sprites for multi-character
     area conversations. */
}
.search-result-icon > img {
  width: 100%;
  height: 100%;
  /* Event logos are wide rectangles; chibi/card thumbs are squarer. The
     fixed square box + object-fit:contain handles both gracefully. */
  object-fit: contain;
  display: block;
}
/* Multi-character actionset hits: pack up to 4 chibis into a 2x2 grid.
   1 char  -> single <img> (handled above)
   2 chars -> top row only (1fr 1fr / 1fr), images fill their half
   3 chars -> three cells of a 2x2 (TL, TR, BL); last cell stays empty
   4 chars -> all four corners filled */
.search-result-icon-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  grid-template-rows: 1fr 1fr;
  gap: 1px;
  /* No padding — the cells are already small (28px nominal). The bg shows
     through the 1px gap to visually separate adjacent chibis. */
}
.search-result-icon-grid.n-2 {
  grid-template-rows: 1fr;
}
.search-result-icon-grid > img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
  min-width: 0;
  min-height: 0;
}
.search-result-card.has-icon .search-result-main {
  flex: 1;
  min-width: 0; /* allow crumb ellipsis inside the flex item */
}
.search-result-card:hover {
  transform: translateY(-1px);
  box-shadow: var(--shadow-md);
  border-color: var(--line-strong);
}
.search-result-meta {
  display: flex; justify-content: space-between; align-items: baseline;
  gap: 8px;
  font-size: 11px;
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  margin-bottom: 6px;
}
/* Wraps the region badge + crumb on the left of the meta row so the
   parent's `space-between` only pushes the line counter to the right edge
   (and not also between region and crumb). */
.search-result-meta-left {
  display: inline-flex; align-items: baseline; gap: 8px;
  min-width: 0; /* allow inner crumb ellipsis */
  flex: 1;
  overflow: hidden;
}
/* Region badge — small uppercase chip indicating which master DB the hit
   came from. Only present on cross-region unified search results. We use
   neutral tones rather than per-region colors so the chip never overpowers
   the unit-color border-left stripe. */
.search-result-region {
  flex-shrink: 0;
  display: inline-block;
  padding: 1px 6px;
  border-radius: 4px;
  background: var(--surface-3, rgba(255, 255, 255, 0.06));
  color: var(--ink-soft, var(--ink));
  font-size: 10px;
  font-weight: 700;
  letter-spacing: 0.1em;
  line-height: 1.4;
  font-variant-numeric: tabular-nums;
  border: 1px solid var(--line, rgba(255, 255, 255, 0.08));
  text-transform: uppercase;
}
.search-result-crumb {
  font-weight: 700;
  color: var(--ink-soft);
  text-transform: none;
  letter-spacing: 0;
  font-size: 12px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.search-result-line {
  flex-shrink: 0;
  font-variant-numeric: tabular-nums;
}
.search-result-row {
  display: flex;
  gap: 12px;
  align-items: baseline;
  line-height: 1.5;
}
.search-result-speaker {
  flex-shrink: 0;
  font-weight: 700;
  color: var(--accent);
  font-size: 14px;
  min-width: 80px;
  max-width: 140px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.search-no-speaker {
  color: var(--muted);
  font-weight: 500;
  font-style: italic;
}
.search-result-body {
  color: var(--ink);
  font-size: 14px;
  flex: 1;
  word-break: break-word;
}
.search-result-card mark {
  background: rgba(255, 209, 102, 0.5); /* --accent-4 with alpha */
  color: var(--ink);
  padding: 0 2px;
  border-radius: 3px;
  font-weight: 700;
}

/* Unit color stripe — uses the existing --u-* vars to tint the left edge of
   unit-story results. Event results have no unit so they fall through to
   the default no-stripe look. */
.search-result-card.u-light_sound    { border-left: 3px solid var(--u-light_sound); }
.search-result-card.u-idol           { border-left: 3px solid var(--u-idol); }
.search-result-card.u-street         { border-left: 3px solid var(--u-street); }
.search-result-card.u-theme_park     { border-left: 3px solid var(--u-theme_park); }
.search-result-card.u-school_refusal { border-left: 3px solid var(--u-school_refusal); }
.search-result-card.u-piapro         { border-left: 3px solid var(--u-piapro); }

/* Skeleton row matching final result-card dimensions. */
.skel-search-result {
  height: 78px;
  border-radius: var(--radius);
}

/* On narrow viewports (<= 700px) the speaker no longer needs a fixed column
   so the body line gets to use the full row. */
@media (max-width: 700px) {
  .search-result-row { flex-direction: column; gap: 4px; }
  .search-result-speaker { min-width: 0; max-width: none; }
}

/* ============================================================
   C6.4 — Advanced search panel + kind-filter chips
   ============================================================ */

.search-empty-hint {
  margin-top: 22px;
  color: var(--muted);
  font-size: 13px;
}
.search-empty-hint code {
  background: var(--surface-2);
  padding: 1px 6px;
  border-radius: 4px;
  font-size: 12px;
  color: var(--ink-soft);
}
.search-empty-hint a { color: var(--accent); font-weight: 700; }

/* C5d: .search-advanced panel retired — the speaker facet moved to the
   appbar's unified topbar-search-cluster so the URL hash is the single
   source of truth. Body facet is dropped from the UI but `?body=` and
   `body:` inline prefix syntax are still honored by parseSearchHash. */

/* Kind-filter chips. Each is a toggle button with a count badge. The 'All'
   pill clears the kinds filter. Empty (zero-count) chips are dimmed so the
   active corpora are visually obvious. */
.search-kind-chips {
  display: flex; flex-wrap: wrap; gap: 6px;
  margin: 0 0 14px;
}
.search-kind-chip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 5px 10px;
  border-radius: var(--radius-pill);
  background: var(--surface);
  border: 1px solid var(--line);
  color: var(--ink-soft);
  font-size: 13px;
  font-weight: 700;
  font-family: inherit;
  cursor: pointer;
  transition:
    background .12s ease,
    border-color .12s ease,
    color .12s ease,
    transform .12s ease;
}
.search-kind-chip:hover {
  background: var(--surface-2);
  border-color: var(--accent);
  color: var(--ink);
  transform: translateY(-1px);
}
.search-kind-chip.is-active {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
}
.search-kind-chip.is-active:hover { color: #fff; }
.search-kind-chip.is-empty {
  opacity: .45;
  cursor: default;
}
.search-kind-chip.is-empty:hover {
  background: var(--surface);
  border-color: var(--line);
  color: var(--ink-soft);
  transform: none;
}
.search-kind-chip-count {
  font-size: 11px;
  font-weight: 700;
  padding: 1px 7px;
  border-radius: var(--radius-pill);
  background: var(--surface-2);
  color: var(--muted);
  min-width: 24px;
  text-align: center;
}
.search-kind-chip.is-active .search-kind-chip-count {
  background: rgba(255,255,255,.18);
  color: #fff;
}
.search-kind-chip:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* ============================================================
   C6.5 — Infinite-scroll sentinel
   ============================================================ */
.search-infinite-sentinel {
  height: 60px;
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--muted);
  font-size: 12px;
  font-weight: 700;
}
.search-infinite-sentinel::before {
  content: "";
  width: 18px; height: 18px;
  border-radius: 50%;
  border: 2px solid var(--surface-2);
  border-top-color: var(--accent);
  animation: pjsk-spin 0.8s linear infinite;
  opacity: 0;
  transition: opacity .12s ease;
}
.search-infinite-sentinel.is-loading::before { opacity: 1; }
@keyframes pjsk-spin {
  to { transform: rotate(360deg); }
}
