/* ============================================================
   Refsheet — design system (dark, modern)
   Per-character accent is injected via the --accent custom property.
   ============================================================ */

:root {
  /* Pinned rather than left to the <meta>, because light-dark() in the per-character accent
     (AccentStyle.For) reads the used value of this property. */
  color-scheme: dark light;

  /* One drab hue (~45°, green-brown) runs the whole ramp — surfaces, borders and text alike.
     A neutral grey ramp would not do: greys pick up the complement of whatever sits on them,
     and under warm character accents a grey surface reads cold, which is what made the old
     blue-slate ramp fight the artwork. Keeping every step on the same hue as the accents means
     the chrome recedes instead of arguing.
     The channel spread widens as the steps lighten (9 -> 24) so the hue survives being nearly
     black at the bottom without turning the panels into mud at the top. */
  --bg: #17150e;
  --surface: #221f16;
  --surface-2: #2d281c;
  --surface-3: #383123;
  --border: #423a2a;
  /* Warm rather than pure white, or the hairlines read as a blue-grey seam on a brown panel. */
  --border-soft: rgba(255, 247, 227, 0.07);

  --text: #f0ece0;
  --text-dim: #b8b09a;
  --text-faint: #857d6b;

  --accent: #a8925e; /* overridden per character */
  /* Readable companions derived alongside --accent (AccentStyle.For): --accent-text is the
     accent pushed away from the page until it contrasts with it — towards white on dark,
     towards black on light, hence the light-dark() pair — and --accent-ink is the text colour
     for things FILLED with the accent (buttons, switch thumbs), which needs no such split. */
  --accent-text: light-dark(#746441, #a8925e);
  --accent-ink: #17140d;

  --radius: 18px;
  --radius-sm: 11px;
  --dur: 240ms;
  --dur-slow: 450ms;
  --fade: 320ms; /* artwork materialising as it decodes */
  --ease: cubic-bezier(0.2, 0.7, 0.2, 1);
  --shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
  --shadow-sm: 0 6px 22px rgba(0, 0, 0, 0.35);

  --font: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --mono: ui-monospace, "Cascadia Code", "SF Mono", Menlo, Consolas, monospace;
  /* The face a character's name is set in. Named per variant in the database and set by InfoPanel;
     defaults to the site face, so a character without one reads exactly as it did before. */
  --name-font: var(--font);

  /* soft neumorphic shadows for tactile surfaces */
  --neu: 6px 6px 14px rgba(0, 0, 0, 0.45), -5px -5px 12px rgba(255, 247, 227, 0.035);
  --neu-inset: inset 3px 3px 7px rgba(0, 0, 0, 0.5), inset -3px -3px 7px rgba(255, 247, 227, 0.03);

  --maxw: 1120px;

  /* Layout constants shared with JS: --bp-narrow is duplicated as the matchMedia query in
     refsheet.js (initRail) — CSS can't drive JS, so change both together. */
  --rail-w: 268px;
  --bp-narrow: 900px;   /* rail docks / becomes an overlay */
  --bp-wide: 1240px;    /* extra breathing room for the character page */
  --bp-stack: 820px;    /* hero / sheet-row collapse to one column; 821px min-width pairs with it */

  /* Accent washes. --accent-glow is the go-to tint for hover glows and brand drop-shadows. */
  --accent-glow: color-mix(in srgb, var(--accent) 45%, transparent);
  --accent-hi-border: color-mix(in srgb, var(--accent) 45%, var(--border));
  --accent-bg-soft: color-mix(in srgb, var(--accent) 16%, transparent);
}

/* ---------- character name faces ----------
   Registered here; which character wears which is content, and lives in the database
   (CharacterVariant.NameFont). They are served from wwwroot/fonts rather than from blob storage
   with the artwork, because font-src is 'self' and nothing else would be allowed to load.

   local() first, so a visitor who already has the face installed uses it and fetches nothing. The
   family names are the faces' own, read out of their name tables, which is what makes that match.

   Neither carries a font-weight descriptor, so each family is one face at 400. That is the truth
   about them rather than an omission — which is why .info-name turns synthesis off instead of
   claiming a weight range here that the files do not contain.

   Both are woff2 subsets, cut to the alphabet a NAME can be written in — A-Z, a-z, the Czech and
   common European diacritics, and the three separators — rather than to the names themselves.
   Subsetting to the live strings is four times smaller again and was rejected for it: names are
   database rows, so renaming Kirion to Kirián would drop the á to a fallback face mid-word, and
   nothing in a build would catch it. README documents how to regenerate them.

   Ronthel Brush keeps its liga and kern tables, which a joining brush face needs, and drops aalt
   — that one is a design application's glyph picker, never applied by a browser, and its
   alternates were a third of the file.

   A name whose face resolves neither way falls back to the site's, exactly as a variant naming no
   font does, and font-display keeps that fallback on screen while the file arrives — so a slow
   font is never a blank heading. */
@font-face {
  font-family: "Beyond Wonderland";
  src: local("Beyond Wonderland"), url("fonts/beyond-wonderland.woff2") format("woff2");
  font-display: swap;
}
@font-face {
  font-family: "Ronthel Brush";
  src: local("Ronthel Brush"), url("fonts/ronthel-brush.woff2") format("woff2");
  font-display: swap;
}

*, *::before, *::after { box-sizing: border-box; }

html, body { height: 100%; }

body {
  margin: 0;
  font-family: var(--font);
  color: var(--text);
  background: var(--bg);
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
}

/* Registering --accent lets it INTERPOLATE (a plain custom property would snap), so accent-tinted
   UI morphs smoothly between characters/variants instead of jumping. */
@property --accent {
  syntax: "<color>";
  inherits: true;
  initial-value: #a8925e;
}

h1, h2, h3, h4 { line-height: 1.2; margin: 0; font-weight: 700; letter-spacing: -0.01em; }

a { color: var(--accent-text); text-decoration: none; }

img { max-width: 100%; display: block; }

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

/* ---------- surface treatments ---------- */
.glass {
  background: color-mix(in srgb, var(--surface) 55%, transparent);
  backdrop-filter: blur(26px) saturate(1.5);
  -webkit-backdrop-filter: blur(26px) saturate(1.5);
  border: 1px solid var(--border-soft);
  box-shadow: inset 0 1px 0 rgba(255, 247, 227, 0.06), var(--shadow-sm);
}

/* ---------- left nav rail (character list) ---------- */
.rail-nav {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  flex: 1;
  min-height: 0;
}

.rail-item {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.7rem;
  padding: 0.45rem 0.55rem;
  border-radius: 14px;
  color: var(--text-dim);
  text-decoration: none;
  transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
}
.rail-item:hover { background: color-mix(in srgb, var(--surface-3) 65%, transparent); color: var(--text); }
.rail-item.active { color: var(--text); background: var(--accent-bg-soft); }
.rail-item.active::before {
  content: "";
  position: absolute;
  left: 0; top: 50%;
  transform: translateY(-50%);
  width: 3px; height: 62%;
  border-radius: 0 3px 3px 0;
  background: var(--accent);
  box-shadow: 0 0 10px color-mix(in srgb, var(--accent) 70%, transparent);
}

.rail-avatar {
  width: 42px; height: 42px;
  flex: 0 0 auto;
  border-radius: 50%;
  overflow: hidden;
  display: grid;
  place-items: center;
  background: linear-gradient(160deg, var(--surface-3), var(--surface));
  box-shadow: var(--neu);
  transition: border-radius var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}
.rail-item:hover .rail-avatar,
.rail-item.active .rail-avatar { border-radius: 30%; }
.rail-item.active .rail-avatar { box-shadow: 0 0 0 2px var(--accent), var(--neu); }
.rail-avatar img { width: 100%; height: 100%; object-fit: cover; }
.rail-avatar-fallback { font-weight: 800; color: var(--accent-text); }

.rail-name { font-size: 0.92rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

.rail-item--skeleton { height: 52px; }

/* ---------- app shell / layout ---------- */
.shell { min-height: 100vh; display: flex; flex-direction: column; }

.app-body { display: flex; flex-direction: column; flex: 1; min-width: 0; }
.app-main { flex: 1; }
.app-footer {
  display: flex; justify-content: center; align-items: center; gap: 0.75rem;
  padding: 2rem 1rem; color: var(--text-faint); font-size: 0.85rem;
  border-top: 1px solid var(--border);
}
.app-footer .dot { opacity: 0.5; }

.brand { display: inline-flex; align-items: center; gap: 0.5rem; text-decoration: none; color: var(--text); font-weight: 700; letter-spacing: 0.02em; }
.brand-mark { color: var(--accent-text); width: 26px; height: 26px; flex: 0 0 auto; filter: drop-shadow(0 2px 8px var(--accent-glow)); transition: transform var(--dur) var(--ease); }
.brand:hover .brand-mark { transform: translateY(-1px) scale(1.08); }
.brand-name { font-size: 1.1rem; background: linear-gradient(120deg, var(--text), var(--accent)); -webkit-background-clip: text; background-clip: text; color: transparent; }

/* landing: simple sticky top bar (no rail) */
.topbar {
  position: sticky; top: 0; z-index: 40;
  display: flex; align-items: center; justify-content: space-between; gap: 2rem;
  padding: 0.85rem clamp(1rem, 4vw, 3rem);
  background: color-mix(in srgb, var(--bg) 72%, transparent);
  backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
  border-bottom: 1px solid var(--border);
}
.topbar-nav a { color: var(--text-dim); text-decoration: none; transition: color var(--dur) var(--ease); }
.topbar-nav a:hover { color: var(--text); }

/* character view: fixed, collapsible left rail */
.shell--railed { display: block; }
.shell--railed .app-body { margin-left: var(--rail-w); min-height: 100vh; transition: margin-left var(--dur) var(--ease); }

.rail {
  position: fixed; top: 0; left: 0; bottom: 0; width: var(--rail-w); z-index: 50;
  display: flex; flex-direction: column; gap: 1rem;
  padding: 1rem 0.9rem;
  border-right: 1px solid var(--border);
  overflow-y: auto;
  transition: transform var(--dur) var(--ease);
}
.rail-head { display: flex; align-items: center; gap: 0.55rem; padding: 0.25rem; }

.burger { appearance: none; border: 0; background: transparent; cursor: pointer; width: 34px; height: 34px; border-radius: 9px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; flex: 0 0 auto; }
.burger span { display: block; width: 18px; height: 2px; background: var(--text-dim); border-radius: 2px; transition: background var(--dur) var(--ease); }
.burger:hover { background: var(--surface-3); }
.burger:hover span { background: var(--text); }

.rail-open {
  position: fixed; top: 0.8rem; left: 0.8rem; z-index: 45;
  width: 42px; height: 42px; border-radius: 12px; cursor: pointer;
  color: var(--text); font-size: 1.15rem;
  display: none; align-items: center; justify-content: center;
}

/* collapsed state — toggled on <html> by refsheet.toggleRail() */
html.rail-collapsed .rail { transform: translateX(-100%); }
html.rail-collapsed .shell--railed .app-body { margin-left: 0; }
html.rail-collapsed .rail-open { display: flex; }

/* When collapsed, the floating ☰ sits top-left over the content. Nudge the page's own
   top bar ("← All characters") over so the button never overlaps it. On wide screens the
   centred content already clears the button, so the reservation is dropped. */
html.rail-collapsed .character-topbar,
html.rail-collapsed .notfound { padding-left: 3.6rem; }
@media (min-width: 1240px) /* --bp-wide */ {
  html.rail-collapsed .character-topbar,
  html.rail-collapsed .notfound { padding-left: 0; }
}

/* mobile: rail overlays content instead of pushing it */
@media (max-width: 900px) /* --bp-narrow */ {
  .shell--railed .app-body { margin-left: 0; }
  .rail { width: min(82vw, 300px); box-shadow: var(--shadow); }
  .brand-name { font-size: 1.05rem; }
}

/* ---------- buttons ---------- */
.btn {
  appearance: none;
  border: 1px solid var(--border);
  background: var(--surface-2);
  color: var(--text);
  padding: 0.6rem 1.15rem;
  border-radius: var(--radius-sm);
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: transform var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}
.btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(0); }
.btn--accent {
  background: var(--accent);
  border-color: transparent;
  color: var(--accent-ink);
}
.btn--accent:hover { box-shadow: 0 8px 26px var(--accent-glow); }
.btn--ghost { background: transparent; }

/* ============================================================
   Landing page
   ============================================================ */
.home {
  max-width: var(--maxw);
  margin: 0 auto;
  padding: clamp(2rem, 6vw, 5rem) clamp(1rem, 4vw, 2rem) 4rem;
  animation: fade-up var(--dur-slow) var(--ease) backwards;
}

/* Same reason as .character--instant below: the interactive render replaces the prerendered
   nodes, so without this the entrance runs a second time and the page fades in twice. */
.home--instant { animation: none; }

.home-intro { margin-bottom: 2.5rem; }
/* Mark and wordmark on one baseline, echoing the rail head. The mark scales with the
   heading rather than sitting at a fixed size, so the pairing holds as the type responds. */
.home-brand { display: flex; align-items: center; gap: 0.7rem; }
.home-mark {
  width: 1.15em; height: 1.15em; flex: 0 0 auto;
  font-size: clamp(2rem, 5vw, 3.1rem);
  color: var(--accent-text);
  filter: drop-shadow(0 3px 12px var(--accent-glow));
}
.home-intro h1 {
  font-size: clamp(2rem, 5vw, 3.1rem);
  background: linear-gradient(120deg, var(--text), var(--accent));
  -webkit-background-clip: text; background-clip: text; color: transparent;
}
.home-intro p { color: var(--text-dim); max-width: 52ch; margin-top: 0.75rem; }
.home-tagline { font-size: 1.15rem; color: var(--text) !important; margin-top: 1rem !important; }

.grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  gap: 1.5rem;
}

.card {
  position: relative;
  display: flex;
  flex-direction: column;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
  color: var(--text);
  box-shadow: var(--shadow-sm);
  transition: transform var(--dur) var(--ease), border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}
.card::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1px;
  background: linear-gradient(160deg, color-mix(in srgb, var(--accent) 55%, transparent), transparent 45%);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  opacity: 0;
  transition: opacity var(--dur) var(--ease);
  pointer-events: none;
}
.card:hover {
  transform: translateY(-6px);
  border-color: var(--accent-hi-border);
  box-shadow: var(--shadow);
}
.card:hover::after { opacity: 1; }

.card-art {
  aspect-ratio: 4 / 5;
  background: linear-gradient(160deg, var(--surface-3), var(--surface));
  overflow: hidden;
}
.card-art img {
  width: 100%; height: 100%;
  object-fit: cover;
  transition: transform var(--dur-slow) var(--ease), filter var(--fade) var(--ease), opacity var(--fade) var(--ease);
}
.card:hover .card-art img { transform: scale(1.05); }
.card-art--fallback {
  width: 100%; height: 100%;
  display: grid; place-items: center;
  font-size: 4rem; font-weight: 800;
  color: var(--accent-text);
}

.card-body { padding: 1.1rem 1.2rem 1.4rem; }
.card-body h2 { font-size: 1.3rem; }
.card-body p { color: var(--text-dim); font-size: 0.92rem; margin: 0.4rem 0 0; }

.card-go {
  position: absolute;
  right: 1.1rem; bottom: 1.1rem;
  font-size: 0.85rem; font-weight: 600;
  color: var(--accent-text);
  opacity: 0; transform: translateX(-6px);
  transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease);
}
.card:hover .card-go { opacity: 1; transform: translateX(0); }

.card--skeleton { aspect-ratio: 4 / 5; }

.empty { color: var(--text-faint); }

/* ============================================================
   Character page
   ============================================================ */
.character {
  max-width: var(--maxw);
  margin: 0 auto;
  padding: clamp(1.25rem, 4vw, 2.5rem) clamp(1rem, 4vw, 2rem) 5rem;
  animation: fade-up var(--dur-slow) var(--ease) backwards;
  /* smoothly recolour all accent-tinted UI when the variant/character changes */
  transition: --accent var(--dur-slow) var(--ease);
}

.character-topbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  margin-bottom: 2rem;
}
.back { color: var(--text-dim); font-size: 0.92rem; transition: color var(--dur) var(--ease); }
.back:hover { color: var(--text); }

/* --- variant selector --- */
.variant-selector { display: flex; flex-wrap: wrap; gap: 1.25rem; }
.axis { display: flex; align-items: center; gap: 0.6rem; }
.axis-label {
  font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.09em;
  color: var(--text-faint); font-weight: 700;
}
/* animated 2-state toggle switch: clicking anywhere flips it, thumb slides */
.switch {
  position: relative;
  display: flex;
  align-items: stretch;
  padding: 4px;
  border-radius: 999px;
  background: var(--surface);
  border: 1px solid var(--border-soft);
  box-shadow: var(--neu-inset);
  cursor: pointer;
  font: inherit;
  -webkit-tap-highlight-color: transparent;
}
.switch-thumb {
  position: absolute;
  top: 4px; bottom: 4px; left: 4px;
  width: calc(50% - 4px);
  border-radius: 999px;
  background: var(--accent);
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35), 0 0 14px color-mix(in srgb, var(--accent) 22%, transparent);
  transform: translateX(calc(var(--i, 0) * 100%));
  transition: transform var(--dur-slow) var(--ease), background var(--dur) var(--ease);
}
.switch-opt {
  position: relative;
  z-index: 1;
  flex: 1 1 50%;
  text-align: center;
  padding: 0.4rem 1.1rem;
  font-weight: 600;
  font-size: 0.9rem;
  color: var(--text-dim);
  white-space: nowrap;
  transition: color var(--dur) var(--ease);
}
.switch:hover .switch-opt:not(.on) { color: var(--text); }
.switch-opt.on { color: var(--accent-ink); }

/* --- variant content: a subtle, quick crossfade on switch (not the full staggered page entrance) --- */
.variant { display: flex; flex-direction: column; gap: 2.5rem; }
.variant { animation: variant-in 300ms var(--ease) backwards; }

/* Set when the interactive render is repainting markup the prerender already showed. The
   nodes are new, so both entrance animations would otherwise run a second time and the page
   visibly fades in twice. Covers .variant as well: it sits inside the replaced subtree. */
.character--instant,
.character--instant .variant { animation: none; }

/* --- hero: illustration + info --- */
.hero {
  display: grid;
  /* Weighted towards the info panel: it carries two columns of label/value pairs, and values
     like "Sleek, lithe, lanky, a bit skinny" wrapped at the old split. The illustration is a
     fixed 4:5 crop, so the width it gives up costs it nothing. */
  grid-template-columns: minmax(0, 0.72fr) minmax(0, 1.28fr);
  gap: 2rem;
  align-items: start;
}
@media (max-width: 820px) /* --bp-stack */ { .hero { grid-template-columns: 1fr; } }

.illustration {
  position: sticky; top: 5rem;
  border-radius: var(--radius);
  overflow: hidden;
  border: 1px solid var(--border);
  background: linear-gradient(160deg, var(--surface-2), var(--surface));
  box-shadow: var(--shadow);
}
@media (max-width: 820px) /* --bp-stack */ { .illustration { position: static; } }
.illustration-img { width: 100%; aspect-ratio: 4 / 5; object-fit: cover; transition: filter var(--fade) var(--ease), opacity var(--fade) var(--ease); }
.illustration-fallback {
  aspect-ratio: 4 / 5; display: grid; place-items: center;
  color: var(--accent-text);
  font-weight: 800; font-size: 1.6rem;
}

.info {
  padding: clamp(1.25rem, 3vw, 2rem);
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--surface) 48%, transparent);
  backdrop-filter: blur(32px) saturate(1.6);
  -webkit-backdrop-filter: blur(32px) saturate(1.6);
  border: 1px solid var(--border-soft);
  box-shadow: inset 0 1px 0 rgba(255, 247, 227, 0.06), var(--shadow-sm);
}

.info-name {
  /* --name-font already defaults to --font; the second entry is the fallback for when a variant
     HAS named a face and it fails to load. */
  font-family: var(--name-font), var(--font);
  /* Each name face is registered at one weight, so h1's 700 has no real bold to match and the
     browser INVENTS one by smearing the glyphs outwards. A text face survives that; a script or
     brush face does not — strokes thicken unevenly and the counters close up.
     Suppressing synthesis rather than setting the weight to 400 keeps the request at 700, which
     is what the fallback still wants: nothing here blocks a REAL bold, so a name that falls back
     to the site face renders bold exactly as every other heading does. */
  font-synthesis: none;
  font-size: clamp(2rem, 5vw, 3rem);
  background: linear-gradient(120deg, var(--text), color-mix(in srgb, var(--accent) 85%, var(--text)));
  -webkit-background-clip: text; background-clip: text; color: transparent;
  margin-bottom: 1.25rem;
}

.stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  /* Row gap stays 0 — rows are separated by their bottom border, and a gap would break the
     continuous rule. The column gap is what keeps the left column's value off the right
     column's label. */
  column-gap: 2.5rem;
  row-gap: 0;
  margin: 0;
  border-top: 1px solid var(--border-soft);
}
.stat {
  display: flex; justify-content: space-between; gap: 1rem;
  padding: 0.7rem 0.2rem;
  border-bottom: 1px solid var(--border-soft);
}
/* nowrap: two-word labels like "Body type" broke across two lines and read worse than a
   wrapped value does. The label is short and fixed; the value is the part that can give. */
.stat dt { color: var(--text-faint); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.07em; font-weight: 700; padding-top: 0.15rem; white-space: nowrap; }
.stat dd { margin: 0; text-align: right; color: var(--text); font-weight: 500; }

.about { margin-top: 1.75rem; }
.about p { color: var(--text-dim); margin-top: 0.6rem; }

.section-title {
  font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.1em;
  color: var(--accent-text); font-weight: 700; margin-bottom: 0.9rem;
}

/* --- reference sheet --- */
.refsheet-frame {
  position: relative;
  display: block; width: 100%;
  padding: 0; border: 1px solid var(--border);
  background: linear-gradient(160deg, var(--surface-2), var(--surface));
  border-radius: var(--radius); overflow: hidden;
  cursor: zoom-in;
  transition: border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}
.refsheet-frame:hover { border-color: var(--accent-hi-border); box-shadow: var(--shadow); }
.refsheet-frame img { width: 100%; height: auto; transition: filter var(--fade) var(--ease), opacity var(--fade) var(--ease); }

/* Artwork materialises out of its placeholder surface instead of snapping in. Every one of
   these four containers already paints a gradient or surface tint, which is what the image
   fades out of; nav avatars are 42px and the lightbox has its own entrance, so both are
   left alone.

   @starting-style, not a JS-applied class. The declared state is fully visible and the
   entry state exists only for the frame an element is inserted, so there is no marker for
   anything to strip: a browser without support just shows the image. The previous two
   attempts both failed on exactly that point — they made invisibility the default and
   relied on script to undo it, so a DOM morph that reset the class left images hidden for
   good. Blazor's enhanced navigation was measured doing precisely that.

   The trade is that this keys off the element being inserted rather than the image finishing
   its decode, so a slow image fades its box in and then fills. Acceptable now that the boxes
   are size-reserved and the images are sized to how they render — thumbnails are 40-60 KB. */
:is(.tile, .illustration, .refsheet-frame, .card-art) img {
  opacity: 1;
  filter: blur(0);
}
@starting-style {
  :is(.tile, .illustration, .refsheet-frame, .card-art) img {
    opacity: 0;
    filter: blur(10px);
  }

  /* The same problem .home--instant and .character--instant exist for, arriving by a different
     route: going interactive throws the prerendered DOM away, so every image is INSERTED a
     second time and its entry state runs again — a second later, after the reader has already
     watched the artwork arrive. Those two classes say "animation: none", which cannot reach
     this: @starting-style drives a transition, not an animation. The full account of why the
     DOM is replaced, and of all three symptoms, is on CharacterPage._alreadyPainted.

     Neutralising the entry state rather than the transition is deliberate. `transition: none`
     would reach it, but these images also transition transform — the card and tile hover
     scales — and the guard classes stay on for the life of the page, so it would snap those
     for good. Starting where it ends means there is simply nothing to interpolate. */
  :is(.home--instant, .character--instant) :is(.tile, .illustration, .refsheet-frame, .card-art) img {
    opacity: 1;
    filter: blur(0);
  }
}
.refsheet-frame--empty { display: grid; place-items: center; min-height: 180px; color: var(--text-faint); cursor: default; }

/* Title and rating switch share a row. The switch itself reuses .switch from the variant
   selector, so there is nothing to style here beyond the row.

   Both this and the palette's own title reserve the same height and the same gap to what
   follows: side by side, unequal headers would start the swatches and the sheet frame at
   different heights. The gap matches .character-topbar's, so the rating switch sits above
   its sheet exactly as the variant switches sit above the panels below them. */
.refsheet-head {
  display: flex; align-items: center; justify-content: space-between;
  gap: 1rem; flex-wrap: wrap;
  margin-bottom: 2rem;   /* matches .character-topbar, so the rating switch sits above its
                            sheet exactly as the variant switches sit above the panels */
}
.refsheet-head .section-title { margin-bottom: 0; }

/* The mature sheet before the gate is answered. No <img> is rendered until it is, so the
   height cannot come from the art — the component hands the sheet's own ratio in instead,
   and the veil fills exactly the box the art will. The fallback ratio only covers sheets
   stored without dimensions; a fixed min-height would misreport every other sheet.

   Flex rather than grid: a grid gave the mark and the label a row each and centred them
   inside those, which on a sheet-sized box parked them at the quarter marks instead of
   together in the middle. */
.refsheet-frame--veiled {
  display: flex; flex-direction: column;
  align-items: center; justify-content: center; gap: 0.75rem;
  aspect-ratio: var(--sheet-ratio, 3 / 2);
  color: var(--text-dim); cursor: pointer;
  container-type: inline-size;   /* so the mark below can size itself against this box */
}
/* A reader who already answered the gate should not be shown it again on the way in. The
   prerender cannot know that — only the browser does — so refsheet.js puts .age-ok on <html>
   before the first paint and the veil is simply never seen; the component replaces it with the
   art once it hydrates, which on a slow connection is a long time to have been staring at 🔞.
   Hidden rather than removed, because the box it holds is the size the sheet is about to be and
   the page should not jump when that happens. Hiding the button itself, not its contents, so
   there is nothing invisible left over to click. */
html.age-ok .refsheet-frame--veiled { visibility: hidden; }

/* Sized against the frame rather than the surrounding text: this box is as large as the
   artwork, and a text-sized mark vanishes in it. */
.veil-icon { font-size: clamp(2.5rem, 8cqw, 5rem); line-height: 1; }
.veil-text { font-size: 1rem; font-weight: 600; }
.refsheet-zoom {
  position: absolute; right: 1rem; bottom: 1rem;
  background: color-mix(in srgb, var(--bg) 70%, transparent);
  backdrop-filter: blur(6px);
  border: 1px solid var(--border);
  padding: 0.35rem 0.7rem; border-radius: 999px; font-size: 0.8rem; font-weight: 600;
  color: var(--text);
  opacity: 0; transform: translateY(6px);
  transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease);
}
.refsheet-frame:hover .refsheet-zoom { opacity: 1; transform: translateY(0); }

/* --- sheet + palette: layout follows the artwork ---
   DEFAULT = stacked, i.e. the original flow — the reference sheet takes the full width and the
   palette wraps underneath. That is what a WIDE sheet gets, and what every sheet gets on a
   narrow screen, so the small-screen case needs no rules of its own.
   A SQUARE-ISH sheet leaves usable width beside it, so above the breakpoint the palette moves
   into a margin column and the swatches become a vertical strip.
   DOM order is sheet-then-palette, which is already the stacked order, so the default needs no
   reordering at all. The square case places both explicitly — relying on source order there put
   the sheet in the narrow column and the palette in the wide one. */
.sheet-row { display: grid; grid-template-columns: minmax(0, 1fr); gap: 2.5rem; align-items: start; }

@media (min-width: 821px) /* --bp-stack + 1 */ {
  .sheet-row--square {
  grid-template-columns: clamp(190px, 22%, 250px) minmax(0, 1fr);
  grid-template-rows: auto auto;
  gap: 2rem;
  }
  /* Subgrid, so both sections put their heading in row 1 and their body in row 2. The
     swatches and the sheet frame then start on the same line whichever heading is taller —
     and the rating switch IS taller than a bare title, which knocked them 7px out of step.
     Matching heights by hand would only hold until the switch changed size. Where subgrid is
     unsupported the sections fall back to plain blocks, i.e. today's behaviour. */
  /* Row 1 explicitly on both: the sheet comes first in the markup, so auto-placement would
     take rows 1-2 for it and push the palette to rows 3-4, i.e. underneath. */
  .sheet-row--square .palette,
  .sheet-row--square .refsheet { display: grid; grid-template-rows: subgrid; grid-row: 1 / span 2; }
  /* The 2rem row gap now provides the heading-to-body spacing in this layout. */
  .sheet-row--square .refsheet-head,
  .sheet-row--square .palette > .section-title { margin-bottom: 0; }
  /* Row 1 is as tall as the rating switch, and the head centres its title in it. The palette's
     title is alone in its row, so left to itself it sits at the top and reads as misaligned
     against its neighbour — centre it in the same row rather than nudging it by a margin. */
  .sheet-row--square .palette > .section-title { align-self: center; }
  .sheet-row--square .palette { grid-column: 1; }
  .sheet-row--square .refsheet { grid-column: 2; }
  .sheet-row--square .swatches { flex-direction: column; align-items: stretch; }
  .sheet-row--square .swatch { width: 100%; }
}

/* --- palette ---
   The swatches share a row with the reference sheet, so they take that frame's surface rather
   than one of their own: the same gradient, the same border, the same lift on hover. They used
   to sit on a flat --surface with --neu at rest, which was the only raised-neumorphic surface
   at that size on the page and read as a pill dropped onto it — visibly unlike the illustration
   and info panels either side. --neu-inset stays on :active, where a press SHOULD read as the
   button sinking into the page. */
.swatches { display: flex; flex-wrap: wrap; gap: 0.75rem; }
.swatch {
  display: flex; align-items: center; gap: 0.7rem;
  background: linear-gradient(160deg, var(--surface-2), var(--surface));
  border: 1px solid var(--border);
  border-radius: var(--radius-sm); padding: 0.55rem 0.9rem 0.55rem 0.6rem;
  cursor: pointer; color: var(--text); font: inherit;
  /* A <button> centres its text by default, which centred short names like "Fur" over the
     wider hex line below them while long ones looked left-aligned. The name and the hex have
     to share an edge. */
  text-align: left;
  transition: transform var(--dur) var(--ease), box-shadow var(--dur) var(--ease),
              border-color var(--dur) var(--ease);
}
.swatch:hover {
  transform: translateY(-2px);
  border-color: var(--accent-hi-border);
  box-shadow: var(--shadow), 0 4px 16px color-mix(in srgb, var(--swatch) 22%, transparent);
}
.swatch:active { transform: translateY(0); box-shadow: var(--neu-inset); }
.swatch-chip {
  width: 30px; height: 30px; border-radius: 8px;
  background: var(--swatch);
  /* Theme-aware ring: a white inset line vanished against a pale chip in the light theme. */
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 14%, transparent),
              0 2px 10px color-mix(in srgb, var(--swatch) 24%, transparent);
}
.swatch-meta { display: flex; flex-direction: column; line-height: 1.25; }
.swatch-name { font-size: 0.85rem; font-weight: 600; }
.swatch-hex { font-family: var(--mono); font-size: 0.75rem; color: var(--text-faint); }

/* --- gallery --- */
.gallery-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
  gap: 1rem;
}
.tile {
  position: relative;
  aspect-ratio: 1 / 1;
  border-radius: var(--radius-sm);
  overflow: hidden;
  border: 1px solid var(--border);
  background: var(--surface-2);
}
.tile img { width: 100%; height: 100%; object-fit: cover; transition: transform var(--dur-slow) var(--ease), filter var(--fade) var(--ease), opacity var(--fade) var(--ease); }
.tile:hover img { transform: scale(1.05); }
.tile-open { position: absolute; inset: 0; border: 0; background: transparent; cursor: zoom-in; }

/* Nothing else on a tile says it opens anything, so it borrows the sheet's zoom pill. Tucked
   in tighter than on the sheet, which is the width of the page — an inset sized for that one
   eats a corner of a 180px thumbnail. Where there is a pointer the pill answers the hover it
   was already given; where there is none it simply stays on screen, since a hint that only
   ever appears on hover is no hint at all to a reader holding a phone. */
.tile .refsheet-zoom { right: 0.5rem; bottom: 0.5rem; }
.tile:hover .refsheet-zoom { opacity: 1; transform: translateY(0); }
@media (hover: none) {
  .tile .refsheet-zoom { opacity: 1; transform: none; }
}

/* --- age gate modal --- */
.gate {
  position: fixed; inset: 0; z-index: 200;
  display: flex; align-items: center; justify-content: center; padding: 1.5rem;
  background: rgba(10, 8, 4, 0.72);
  backdrop-filter: blur(6px);
  animation: fade var(--dur) var(--ease) both;
}
.gate-card {
  max-width: 420px; width: 100%;
  background: color-mix(in srgb, var(--surface) 75%, transparent);
  backdrop-filter: blur(22px) saturate(1.3);
  -webkit-backdrop-filter: blur(22px) saturate(1.3);
  border: 1px solid var(--border-soft);
  border-radius: var(--radius); padding: 1.75rem;
  box-shadow: inset 0 1px 0 rgba(255, 247, 227, 0.07), var(--shadow);
  animation: pop var(--dur) var(--ease) backwards;
}
.gate-card h4 { font-size: 1.25rem; }
.gate-card p { color: var(--text-dim); margin: 0.6rem 0 1.4rem; }
.gate-actions { display: flex; justify-content: flex-end; gap: 0.75rem; }

/* --- lightbox --- */
/* Scroll lock. The overlay being in the DOM IS the locked state, so there is nothing to
   set, unset, or leak — it cannot outlive the element, however the lightbox goes away. */
html:has(.lightbox) { overflow: hidden; }

.lightbox {
  position: fixed; inset: 0; z-index: 300;
  display: grid; place-items: center; padding: 3vmin;
  background: rgba(8, 7, 3, 0.88);
  backdrop-filter: blur(10px);
  outline: none;
  animation: fade var(--dur) var(--ease) both;
  /* Every touch gesture in here is ours: swipe pages, pinch zooms, drag pans a zoomed sheet.
     Anything the UA is still allowed to claim it will claim, firing pointercancel mid-gesture,
     so the paging or the pinch simply never happens on the devices they exist for. This was
     pan-y when only the swipe existed — vertical panning was left to the browser — but there
     has never been anything to pan: the overlay locks the document's scroll the moment it
     opens, and the figure is sized to fit the viewport. */
  touch-action: none;
  /* A drag across the artwork should page it, not paint a text selection over the caption. */
  user-select: none;
  -webkit-user-select: none;
}
.lightbox-figure { margin: 0; max-width: 92vw; max-height: 92vh; display: flex; flex-direction: column; gap: 0.75rem; animation: pop var(--dur) var(--ease) backwards; }

/* The stage crops the zoom to the image's own box. A magnified image is bigger than the box it
   was drawn in, and with nothing to clip it, it would spill over the caption and off the edges
   of the screen. The frame — rounded corners and shadow — comes with the crop: left on the img,
   the radius would be drawn on the artwork and then squared off again by this container. */
.lightbox-stage {
  display: flex; overflow: hidden;
  border-radius: var(--radius-sm); box-shadow: var(--shadow);
  /* Not stretch, which is what a flex item defaults to: a caption wider than the artwork would
     widen the stage past the image, and the crop has to be the image's own box exactly — that
     box is what the clamp is computed from, so a wider one would let a pan drift into nothing. */
  align-self: center;
}

/* transform-origin at the box's top-left, matching ZoomGesture: there a local point p renders
   at T + S*p, and that identity is what the focal-point arithmetic is built on — the pointer
   keeps the detail beneath it because both ends agree on the corner they measure from.

   The transition smooths the wheel's discrete notches. A drag turns it off, because there the
   pointer already is the animation and interpolating it only adds lag. */
.lightbox-figure img {
  max-width: 92vw; max-height: 84vh; object-fit: contain;
  transform-origin: 0 0;
  transition: transform 140ms var(--ease);
}
.lightbox--zoomed .lightbox-figure img { cursor: grab; will-change: transform; }
.lightbox--panning .lightbox-figure img { cursor: grabbing; transition: none; }
.lightbox-figure figcaption { text-align: center; color: var(--text-dim); font-size: 0.9rem; }
.lightbox-close {
  position: absolute; top: 1.1rem; right: 1.2rem;
  width: 42px; height: 42px; border-radius: 50%;
  border: 1px solid var(--border); background: var(--surface);
  color: var(--text); font-size: 1.1rem; cursor: pointer;
  transition: transform var(--dur) var(--ease), background var(--dur) var(--ease);
}
.lightbox-close:hover { transform: rotate(90deg); background: var(--surface-3); }

/* prev / next navigation through a gallery set */
.lightbox-nav {
  position: absolute; top: 50%; transform: translateY(-50%);
  width: 52px; height: 52px; border-radius: 50%;
  border: 1px solid var(--border);
  background: color-mix(in srgb, var(--surface) 80%, transparent);
  backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
  color: var(--text); font-size: 1.9rem; line-height: 1; cursor: pointer;
  display: grid; place-items: center;
  transition: transform var(--dur) var(--ease), background var(--dur) var(--ease);
}
.lightbox-nav:hover { background: var(--surface-3); }
.lightbox-prev { left: clamp(0.6rem, 3vw, 1.6rem); }
.lightbox-next { right: clamp(0.6rem, 3vw, 1.6rem); }
.lightbox-prev:hover { transform: translateY(-50%) translateX(-3px); }
.lightbox-next:hover { transform: translateY(-50%) translateX(3px); }
.lightbox-count { display: flex; justify-content: center; gap: 0.75rem; text-align: center; color: var(--text-faint); font-size: 0.8rem; font-variant-numeric: tabular-nums; }
/* The weight is secondary to the counter — same strip, quieter. */
.lightbox-weight { opacity: 0.72; }

/* --- skeleton / not found --- */
/* Shimmer as before, plus a 200ms hold-back: a load that resolves faster than that never
   flashes a loading state at all, which is what made a warm navigation look like a glitch. */
.skeleton, .rail-item--skeleton, .card--skeleton { background: linear-gradient(100deg, var(--surface) 30%, var(--surface-3) 50%, var(--surface) 70%); background-size: 200% 100%; animation: shimmer 1.4s linear infinite, fade 160ms var(--ease) 200ms both; border-radius: var(--radius); }
/* Matches the rail item it stands in for, rather than the shared placeholder radius. */
.rail-item--skeleton { border-radius: 14px; }
.skeleton-bar { height: 44px; width: 320px; max-width: 80%; margin-bottom: 2rem; }
.skeleton-art { aspect-ratio: 4/5; }
.skeleton-info { min-height: 360px; }

/* Visually hidden but in the accessibility tree — e.g. the loading state's heading, which
   exists only so FocusOnNavigate has somewhere to land while the skeleton paints. */
.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
}

.notfound { max-width: var(--maxw); margin: 0 auto; padding: 5rem 1.5rem; text-align: center; }
.notfound h1 { font-size: 2rem; margin-bottom: 0.5rem; }
.notfound p { color: var(--text-dim); margin-bottom: 1.5rem; }

/* ============================================================
   Animations
   ============================================================ */
/* Entrance animations use animation-fill-mode: BACKWARDS, never `both`.
   A forward fill keeps the last keyframe applied for ever, and `transform: none` in a keyframe
   resolves to the identity matrix rather than to `none` — which still creates a containing block
   for fixed-position descendants. With `both`, .home/.character/.variant permanently trapped the
   age-gate modal inside their own box: it rendered at the centre of the PAGE at the container's
   width, so on a short viewport it sat off-screen above the fold.
   Backwards fill still applies the `from` frame before the animation starts (which is the point —
   no flash of the un-animated state), and the `to` frame is identical to each element's natural
   style, so nothing is lost by not filling forwards. */
@keyframes fade-up { from { opacity: 0; transform: translateY(14px); } to { opacity: 1; transform: none; } }
@keyframes variant-in { from { opacity: 0; transform: translateY(8px) scale(0.995); } to { opacity: 1; transform: none; } }
@keyframes fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes pop { from { opacity: 0; transform: scale(0.96) translateY(6px); } to { opacity: 1; transform: none; } }
@keyframes shimmer { to { background-position: -200% 0; } }

/* ============================================================
   Not-yet-interactive controls
   ------------------------------------------------------------
   Prerendering paints the character page's controls before a renderer has attached to them, so
   for a moment they are real-looking buttons that drop every click. aria-disabled states that
   for assistive tech; these rules state it to everyone else, and the components set it straight
   off RendererInfo.IsInteractive so it clears itself the instant the page goes live.

   The dimming is an animation with a delay rather than a plain style, and that is the whole
   trick: the attribute is present in the very first byte of HTML, so a static rule would apply
   immediately and every fast load would flash a greyed-out switch — trading one glitch for
   another. The delay means the state only ever appears on a load slow enough to have earned it,
   and interactivity normally wins the race outright. Reduced motion collapses the duration but
   not the delay, so it keeps that behaviour rather than snapping straight to dimmed.
   ============================================================ */
[aria-disabled="true"] { cursor: progress; }

/* Only the switches dim. A dimmed swatch would misreport the very colour it exists to show, and
   the sheet frame and gallery tiles are artwork — fading those is fading the art. */
.switch[aria-disabled="true"] { animation: control-pending var(--dur) var(--ease) 400ms both; }
@keyframes control-pending { from { opacity: 1; } to { opacity: 0.5; } }

/* smooth cross-document navigations where supported */
@media (prefers-reduced-motion: no-preference) {
  @view-transition { navigation: auto; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
}

/* ============================================================
   Light theme — follows the OS / browser preference automatically.
   Dark is the default (:root above); these tokens override for light.
   ============================================================ */
@media (prefers-color-scheme: light) {
  :root {
  /* The same drab hue as the dark theme, read as paper rather than as shadow: cream and
     unbleached linen where the dark side has bark. Nothing here is neutral grey — a grey ramp
     under a warm accent is what made the old palette read as cold. */
  --bg: #efece1;
  --surface: #fdfbf5;
  --surface-2: #f7f3e8;
  --surface-3: #e9e4d5;
  --border: #d9d2be;
  --border-soft: rgba(48, 40, 20, 0.10);

  --text: #2a2416;
  --text-dim: #5f5745;
  --text-faint: #97907e;

  /* neumorphism flips: dark shadow + bright highlight on a light surface */
  --neu: 6px 6px 14px rgba(60, 50, 25, 0.12), -5px -5px 12px rgba(255, 253, 246, 0.9);
  --neu-inset: inset 3px 3px 7px rgba(60, 50, 25, 0.12), inset -3px -3px 7px rgba(255, 253, 246, 0.85);
  --shadow: 0 18px 50px rgba(60, 50, 25, 0.16);
  --shadow-sm: 0 6px 22px rgba(60, 50, 25, 0.10);
  }
}
