:root {
  --bg-0: #05070a;
  --bg-1: #0a0f16;
  --bg-2: #0f1620;
  --panel: rgba(16, 24, 34, 0.72);
  --panel-border: rgba(103, 214, 255, 0.25);
  --cyan: #5fe3ff;
  --cyan-dim: #2b8ba8;
  --amber: #ffb454;
  --danger: #ff5f6d;
  --ok: #6dffb0;
  --text: #dbe9f0;
  --text-dim: #8fa4b3;
  --text-faint: #5b6b78;
  /* Base/value/body-copy text tone (dossier stat values, description
     prose) -- kept distinct from --text (still used for titles/badges/
     chips) so only the actual readable content picks up this tint, not
     headings. Same color as --cyan-dim (the darker blue already used for
     .entry-subtitle, right under an entry's own title) on request, rather
     than the lighter blue this used to be -- one consistent "body copy"
     blue instead of two different ones. */
  --text-blue: #2b8ba8;
  --mono: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
  --sans: "Segoe UI", system-ui, -apple-system, sans-serif;
}

* { box-sizing: border-box; }

/* ---------- scrollbars: match the dark cyan theme everywhere (page,
   dossier columns, log/glossary panels, etc.) instead of the browser's
   default grey/white ---------- */
:root { scrollbar-color: var(--cyan-dim) rgba(255,255,255,0.05); scrollbar-width: thin; }
*::-webkit-scrollbar { width: 10px; height: 10px; }
*::-webkit-scrollbar-track { background: rgba(255,255,255,0.04); }
*::-webkit-scrollbar-thumb { background: var(--cyan-dim); border-radius: 6px; border: 2px solid transparent; background-clip: padding-box; }
*::-webkit-scrollbar-thumb:hover { background: var(--cyan); }
*::-webkit-scrollbar-corner { background: transparent; }

html, body {
  margin: 0; padding: 0;
  background: radial-gradient(ellipse at 50% -10%, #10202b 0%, var(--bg-0) 55%, #020304 100%);
  color: var(--text);
  font-family: var(--sans);
  min-height: 100vh;
  overscroll-behavior-y: none;
}

/* Installed-as-app (PWA standalone) on iOS: overscrolling past the top/
   bottom normally reveals the native WKWebView background (plain white)
   behind the page during the elastic "rubber-band" bounce, since the
   bounce goes past the whole HTML document's own bounds. Making <html>
   a fixed, non-scrolling frame and <body> the actual scrolling element
   keeps the bounce entirely inside body's own dark background instead --
   the same trick used to kill this on installed iOS web apps generally.
   Harmless in the plain browser tab too (scrolling behaves identically). */
html {
  height: 100%;
  overflow: hidden;
}
body {
  height: 100%;
  overflow-y: auto;
  overflow-x: hidden;
  -webkit-overflow-scrolling: touch;
}

.scanlines {
  position: fixed; inset: 0; pointer-events: none; z-index: 999;
  background: repeating-linear-gradient(
    to bottom, rgba(255,255,255,0.02) 0px, rgba(255,255,255,0.02) 1px,
    transparent 1px, transparent 3px
  );
  mix-blend-mode: overlay;
}

/* ---------- shared spinner ----------
   A ring of small dots at fixed brightness (a "comet tail" gradient) that
   spins as one rigid body via a single transform: rotate animation --
   real, smooth circular motion, nothing blinking on/off. Reused inline
   (via .content-loading below) whenever a not-yet-cached region/category
   is loading mid-session (see app.js), and (a separate copy, since
   admin.html is a standalone page with its own stylesheet) the Log tab's
   own live-activity indicator. There's no full-screen startup splash
   anymore -- the home grid only ever preloads small thumbnails now, fast
   enough that blocking the whole page behind one stopped being worth it. */
.log-spinner {
  position: relative; display: inline-block;
  width: 18px; height: 18px; vertical-align: middle;
}
.log-spinner.hidden { display: none; }
.log-spinner-ring { position: absolute; inset: 0; animation: spin-ring 1s linear infinite; }
.log-spinner-ring i {
  position: absolute; top: 50%; left: 50%; width: 2.5px; height: 2.5px; margin: -1.25px 0 0 -1.25px;
  border-radius: 50%; background: var(--cyan); font-style: normal;
}
.log-spinner-ring i:nth-child(1) { transform: rotate(0deg) translateY(-8px); opacity: 1; }
.log-spinner-ring i:nth-child(2) { transform: rotate(45deg) translateY(-8px); opacity: 0.82; }
.log-spinner-ring i:nth-child(3) { transform: rotate(90deg) translateY(-8px); opacity: 0.64; }
.log-spinner-ring i:nth-child(4) { transform: rotate(135deg) translateY(-8px); opacity: 0.46; }
.log-spinner-ring i:nth-child(5) { transform: rotate(180deg) translateY(-8px); opacity: 0.28; }
.log-spinner-ring i:nth-child(6) { transform: rotate(225deg) translateY(-8px); opacity: 0.16; }
.log-spinner-ring i:nth-child(7) { transform: rotate(270deg) translateY(-8px); opacity: 0.08; }
.log-spinner-ring i:nth-child(8) { transform: rotate(315deg) translateY(-8px); opacity: 0.03; }
@keyframes spin-ring { to { transform: rotate(360deg); } }

/* Shown over the main content area the first time (this session) a region
   or category is opened and its images aren't preloaded yet -- see
   app.js's showContentLoading(). Not a full-screen takeover -- the app
   chrome (topbar/nav) stays visible and usable. */
.content-loading {
  display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px;
  min-height: 50vh; color: var(--text-faint); font-family: var(--mono); font-size: 0.85em; letter-spacing: 1px;
}
.content-loading .log-spinner { width: 28px; height: 28px; }
.content-loading .log-spinner-ring i { width: 3.5px; height: 3.5px; margin: -1.75px 0 0 -1.75px; }

/* ---------- top bar ---------- */
.topbar {
  display: flex; align-items: center; justify-content: space-between; gap: 32px;
  /* gap, not just justify-content:space-between -- space-between alone only
     pushes the brand and the nav button group toward opposite edges when
     there's enough leftover room; once the nav's own button count (now six
     wide, with Delete/New Region/Settings) eats into that room, "Archive"
     could end up sitting right up against the logo with no gap at all.
     gap guarantees a floor on that spacing regardless of width. */
  padding: 16px 28px;
  /* env(safe-area-inset-top) is 0 on any device with no notch/status-bar
     overlay (desktop included), so this is a no-op everywhere except an
     iPhone-style notched screen -- where, combined with index.html/
     admin.html's own viewport-fit=cover meta tag, it nudges VISION ARCHIVE
     / Archive / Classification Codex / etc. down below the status bar
     instead of sitting flush against (or under) it. */
  padding-top: calc(16px + env(safe-area-inset-top));
  border-bottom: 1px solid var(--panel-border);
  background: linear-gradient(to bottom, rgba(10,18,26,0.9), rgba(10,18,26,0.4));
  backdrop-filter: blur(6px);
  position: sticky; top: 0; z-index: 50;
}
.brand { display: flex; align-items: center; gap: 10px; letter-spacing: 3px; font-weight: 600; }
.brand-mark { color: var(--cyan); text-shadow: 0 0 12px var(--cyan); font-size: 1.1em; }
a.brand-mark { text-decoration: none; cursor: pointer; }
a.brand-mark:hover { text-shadow: 0 0 18px var(--cyan); }
.brand-text { color: var(--text); font-size: 0.95em; }
.topnav { display: flex; gap: 10px; }

.nav-btn {
  background: rgba(20, 32, 44, 0.6);
  border: 1px solid var(--panel-border);
  color: var(--text-dim);
  padding: 9px 16px;
  font-family: var(--sans);
  font-size: 0.82em;
  letter-spacing: 0.5px;
  border-radius: 3px;
  cursor: pointer;
  transition: all 0.15s ease;
}
.nav-btn:hover, .nav-btn:focus-visible { color: var(--cyan); border-color: var(--cyan); box-shadow: 0 0 10px rgba(95,227,255,0.25); outline: none; }
.nav-btn.accent { color: var(--bg-0); background: var(--cyan); border-color: var(--cyan); font-weight: 600; }
.nav-btn.accent:hover { box-shadow: 0 0 16px var(--cyan); }
.nav-btn.danger { color: var(--danger); border-color: var(--danger); }
.nav-btn.danger:hover { color: var(--bg-0); background: var(--danger); box-shadow: 0 0 16px var(--danger); }
.nav-btn:disabled { opacity: 0.5; cursor: default; pointer-events: none; }
.nav-btn.hidden { display: none; }

main#app { padding: 28px 34px 80px; max-width: 1400px; margin: 0 auto; }

.muted { color: var(--text-dim); font-size: 0.9em; line-height: 1.5; }
.muted.small { font-size: 0.78em; color: var(--text-faint); margin-top: 14px; }

/* ---------- home / archive grid ---------- */
.section-title {
  font-size: 0.78em; letter-spacing: 3px; color: var(--cyan-dim); text-transform: uppercase;
  margin: 0 0 16px; display: flex; align-items: center; gap: 10px;
}
.section-title::after { content: ""; flex: 1; height: 1px; background: var(--panel-border); }

.hint-bar {
  font-size: 0.78em; color: var(--text-faint); margin-bottom: 22px; font-family: var(--mono);
}
.hint-bar kbd {
  background: rgba(255,255,255,0.06); border: 1px solid var(--panel-border);
  border-radius: 3px; padding: 1px 6px; color: var(--cyan); font-family: var(--mono);
}

.archive-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: 18px;
}

.region-card {
  background: var(--panel);
  border: 1px solid var(--panel-border);
  border-radius: 6px;
  overflow: hidden;
  cursor: pointer;
  transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease;
  outline: none;
}
.region-card:hover, .region-card:focus {
  transform: translateY(-3px);
  border-color: var(--cyan);
  box-shadow: 0 6px 24px rgba(95,227,255,0.18), 0 0 0 1px var(--cyan) inset;
}
/* Bulk-delete select mode (see toggleDeleteSelection() in app.js) -- every
   card dims very slightly so the SELECTED ones (full opacity, danger-red
   glow) read as clearly picked out of the set, rather than everything
   looking the same with just a small checkmark to notice. */
.region-card.delete-mode { opacity: 0.72; transition: opacity 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; }
.region-card.delete-mode:hover { opacity: 0.9; transform: none; }
.region-card.delete-mode.selected {
  opacity: 1; border-color: var(--danger);
  box-shadow: 0 0 0 1px var(--danger) inset, 0 0 16px rgba(255,90,90,0.25);
}
.card-viewport {
  /* aspect-ratio, not a fixed height -- a fixed 130px stayed constant
     while the grid's own card WIDTH shrank a lot more on a narrow mobile
     viewport (down to one column, close to full screen width) than it
     ever does on desktop, so the same object-fit:cover crop ended up
     cutting off far more of the image's top/bottom on mobile than on
     desktop for the exact same source picture. Tying height to width via
     aspect-ratio instead keeps the crop identical at every card width. */
  aspect-ratio: 2 / 1;
  position: relative;
  display: flex; align-items: center; justify-content: center;
  overflow: hidden;
}
.card-viewport::after {
  content: ""; position: absolute; inset: 0;
  background: repeating-linear-gradient(115deg, rgba(255,255,255,0.03) 0 2px, transparent 2px 6px);
}
.card-viewport .glyph { font-size: 2.6em; opacity: 0.55; filter: drop-shadow(0 0 10px currentColor); }
.card-viewport.has-image img {
  width: 100%; height: 100%; object-fit: cover; position: relative; z-index: 1;
}
/* Stands in for the favorite icon (top-right of the viewport) while
   delete-mode is active -- an empty ring, filled with a checkmark and the
   same danger red once that card is selected, same corner/overlay
   positioning .fav-icon--overlay already uses. */
.select-check {
  position: absolute; top: 7px; right: 7px; z-index: 2;
  width: 15px; height: 15px; border-radius: 50%;
  border: 1.5px solid #ffffff; background: transparent;
  display: flex; align-items: center; justify-content: center;
  font-size: 0.68em; line-height: 1; color: var(--bg-0);
}
.region-card.selected .select-check { background: var(--danger); border-color: var(--danger); }

.card-body { padding: 12px 14px 14px; }
.card-code { font-family: var(--mono); font-size: 0.7em; color: var(--text-faint); letter-spacing: 1px; }
.card-name { font-size: 1.02em; font-weight: 600; color: var(--text); }
/* Rename icon docks to the right of the title, same row -- title itself
   is NOT centered here (home-grid cards read left-aligned throughout, this
   isn't the dossier's own centered-title convention), so a plain flex row
   with the icon pushed to the far end is enough; no grid-centering trick
   needed the way .entry-title-row/.subsection-title require. */
.card-name-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 3px 0 8px; }
.card-name-row .rename-icon { flex-shrink: 0; }
.card-badges { display: flex; flex-wrap: wrap; gap: 5px; }

.badge {
  font-size: 0.68em; letter-spacing: 0.5px; padding: 3px 8px; border-radius: 20px;
  border: 1px solid var(--panel-border); color: var(--text-dim); white-space: nowrap;
}
.badge.rumor { color: var(--amber); border-color: var(--amber); box-shadow: 0 0 8px rgba(255,180,84,0.25); }
.badge.hz-1, .badge.hz-2 { color: var(--ok); border-color: var(--ok); }
.badge.hz-3, .badge.hz-4 { color: var(--amber); border-color: var(--amber); }
.badge.hz-5, .badge.hz-6 { color: var(--danger); border-color: var(--danger); }

.empty-state { padding: 60px 20px; text-align: center; color: var(--text-dim); }
.empty-state .glyph { font-size: 2.4em; opacity: 0.4; margin-bottom: 12px; }

/* ---------- home controls: k/j category tabs + sort toggle ---------- */
.home-title-bar { margin-bottom: 10px; }
.home-controls {
  display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
  gap: 10px; margin-bottom: 18px;
}
.category-tabs { display: flex; flex-wrap: wrap; gap: 6px; }
.category-tab { padding: 6px 12px; font-size: 0.75em; }
/* Unknown's own category tab -- just its glyph (see CATEGORY_GLYPH.rumors/
   viewportGlyph's own "✦" placeholder), not the word "Unknown". Amber to
   match every other rumor/unknown accent in the app (the region-card
   badge, the glyph itself). */
.category-tab--icon { font-size: 1.05em; padding: 5px 14px; color: var(--amber); border-color: var(--amber); }
.category-tab--icon:hover { color: var(--amber); border-color: var(--amber); box-shadow: 0 0 10px rgba(255,180,84,0.3); }
.category-tab--icon.accent { color: var(--bg-0); background: var(--amber); border-color: var(--amber); }
.sort-select {
  font-size: 0.75em; padding: 6px 30px 6px 12px; cursor: pointer;
  /* Without this, iOS/mobile Safari ignores most of the custom styling a
     plain <select> inherits from .nav-btn above (background, border,
     color, border-radius) and falls back to its own native light/system
     control chrome instead -- which is why this looked like a completely
     different, un-themed element on mobile versus desktop (where Chrome/
     Firefox/Edge already respect that same CSS with no extra work). This
     forces the same dark .nav-btn box everywhere; the custom arrow below
     replaces the native one that appearance:none also removes. The
     OPENED option list itself is still a native OS picker on iOS Safari
     specifically (a platform popup, not page content) and can't be
     re-themed by CSS at all -- everything that isn't that one native
     popup now matches. */
  appearance: none; -webkit-appearance: none; -moz-appearance: none;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3E%3Cpath d='M5 8l5 5 5-5' fill='none' stroke='%238fa4b3' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 10px center; background-size: 13px;
}
.sort-select option { background: var(--bg-1); color: var(--text); }

/* ---------- favorites: the small hollow/gold-filled circle ---------- */
.fav-icon {
  width: 15px; height: 15px; border-radius: 50%;
  border: 1.5px solid #ffffff; background: transparent;
  cursor: pointer; padding: 0; flex-shrink: 0; transition: all 0.15s ease;
}
.fav-icon:hover { box-shadow: 0 0 6px rgba(255,255,255,0.6); }
.fav-icon.favorited { background: var(--amber); border-color: var(--amber); box-shadow: 0 0 8px rgba(255,180,84,0.65); }
/* overlay: sits over a card/dossier image's top-right corner -- both
   .card-viewport and .dossier-viewport are already position:relative. */
.fav-icon--overlay { position: absolute; top: 7px; right: 7px; z-index: 2; }
/* inline: a standalone circle in normal document flow, top-left of the
   dossier panel, above the entry-code/title/image it belongs to. */
.fav-icon--inline { display: inline-flex; margin-bottom: 14px; }

/* ---------- region dossier ---------- */
/* justify-content: space-between only worked with exactly 2 children (back-
   link/play-btn group, then the page-count text) -- now that
   .nav-progress-track sits between them and needs to actually CONSUME the
   leftover space (flex:1 below) rather than just occupy a slot in a 2-item
   split, gap+flex-grow does the job instead: the track stretches to fill
   whatever's left, and the page-count text on the far right just falls out
   naturally at its own content width. */
.dossier-nav {
  display: flex; align-items: center; gap: 14px;
  margin-bottom: 18px; font-family: var(--mono); font-size: 0.78em; color: var(--text-faint);
}
.dossier-nav .back-link { cursor: pointer; color: var(--cyan-dim); }
.dossier-nav .back-link:hover { color: var(--cyan); }
.dossier-nav-left { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; }

/* Small round playthrough button next to "← Archive" -- reads the whole
   region's narration tab by tab, starting from Overview. */
.play-btn {
  cursor: pointer; color: var(--cyan-dim); border: 1px solid var(--panel-border);
  border-radius: 50%; width: 22px; height: 22px; padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 0.75em; line-height: 1; background: rgba(20,32,44,0.6);
  font-family: var(--sans); transition: all 0.15s ease;
}
.play-btn:hover, .play-btn:focus-visible { color: var(--cyan); border-color: var(--cyan); box-shadow: 0 0 8px rgba(95,227,255,0.25); outline: none; }
.play-btn.active { color: var(--bg-0); background: var(--cyan); border-color: var(--cyan); }

/* The playback progress bar itself -- spans from the play button over to
   the "x/13" page count on the right (see wireNavProgressBar() in app.js).
   A little taller than its resting 4px on hover/touch, purely so the
   click/tap target for seeking is more forgiving than the visual bar
   alone would be. */
.nav-progress-track {
  flex: 1 1 auto; min-width: 40px; height: 4px; border-radius: 2px;
  background: rgba(255,255,255,0.08); cursor: pointer; position: relative;
  /* Lets a drag-to-scrub gesture (see wireNavProgressBar in app.js) move
     freely back and forth across the track without the browser also
     trying to scroll/pan the page underneath a touch drag. */
  touch-action: none;
}
.nav-progress-track:hover { height: 6px; }
.nav-progress-fill {
  height: 100%; width: 0%; border-radius: 2px;
  background: linear-gradient(90deg, var(--cyan-dim), var(--cyan));
  box-shadow: 0 0 6px rgba(95,227,255,0.5); pointer-events: none;
}

.entry-strip { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 22px; }
.entry-chip {
  font-size: 0.7em; padding: 5px 10px; border-radius: 3px; border: 1px solid var(--panel-border);
  color: var(--text-faint); cursor: pointer; font-family: var(--mono);
}
.entry-chip:hover { color: var(--cyan); border-color: var(--cyan); }
.entry-chip.active { color: var(--bg-0); background: var(--cyan); border-color: var(--cyan); font-weight: 600; }
/* Unknown's own tab chip -- just its glyph, amber-themed same as
   .category-tab--icon above (see that rule's own comment). Stays amber
   (icon + border) no matter which tab is actually open, including while
   plain .entry-chip/.entry-chip:hover would otherwise fade it to
   --text-faint or flip it to cyan -- :not(.active) here carries higher
   specificity than either of those (two classes vs. one class or one
   class+pseudo-class), so it wins outright rather than depending on
   source order. Only the chip's OWN active state (you're actually on the
   Unknown tab) gets the different filled-background treatment, matching
   every other active chip's own convention. */
.entry-chip--icon:not(.active) { font-size: 1.05em; padding: 3px 12px; color: var(--amber); border-color: var(--amber); }
.entry-chip--icon.active { font-size: 1.05em; padding: 3px 12px; color: var(--bg-0); background: var(--amber); border-color: var(--amber); }
/* Invisible zero-height spacer app.js inserts right before the Unknown
   chip, when one exists -- flex-basis:100% claims the whole row's width for
   itself, which is what forces the very next flex item (Unknown) onto a
   fresh line of its own, unconditionally, rather than only when it happens
   not to fit. Inert on desktop (display:none); mobile-only (see below),
   since a full region's normal tabs (Overview + up to 3 biomes + up to 6
   flora/fauna + up to 3 sites) already wrap cleanly there on their own --
   only the rare 14th Unknown tab needs to be pulled onto its own line. */
.entry-strip-break { display: none; flex-basis: 100%; height: 0; }
@media (max-width: 900px) {
  /* justify-content:center, not just for the Unknown break above -- with
     flex-wrap, each wrapped line is centered independently, so this also
     centers whatever's left over on the last line of the normal tabs
     (e.g. 2 leftover site chips) instead of leaving them hugging the left
     edge. */
  .entry-strip { justify-content: center; }
  .entry-strip-break { display: block; }
}

.dossier-panel {
  background: var(--panel); border: 1px solid var(--panel-border); border-radius: 8px;
  padding: 30px 34px; position: relative; overflow: hidden;
}
.dossier-panel::before {
  content: ""; position: absolute; top: 0; left: 0; right: 0; height: 2px;
  background: linear-gradient(90deg, transparent, var(--cyan), transparent);
  opacity: 0.6;
}
.dossier-viewport {
  /* No height of its own anymore -- .dossier-col-image (below) owns the
     actual height budget and this just fills whatever's left in that
     column after the audio box beneath it takes its share, via flex:1 1
     auto + min-height:0. That's what keeps the image, in every case,
     short enough to leave the audio/play box visible on screen instead of
     pushing it past the fold. width:100% stated explicitly (not just left
     to flex's default cross-axis stretch) so it's unambiguously the same
     width as .hologram-panel--below-image below it on mobile/tablet, which
     sets its own width the same explicit way -- both are then guaranteed
     equal regardless of either box's own content (an image with large
     intrinsic dimensions can otherwise nudge a flex item's computed width
     via its default min-width:auto). */
  width: 100%;
  flex: 1 1 auto; min-height: 0;
  border-radius: 6px; margin-bottom: 0;
  display: flex; align-items: center; justify-content: center; position: relative; overflow: hidden;
  background: rgba(0,0,0,0.2);
}
.dossier-viewport.has-image {
  outline: 1px solid var(--panel-border); outline-offset: 8px;
}
.dossier-viewport .glyph { font-size: 4em; opacity: 0.5; filter: drop-shadow(0 0 16px currentColor); }
.dossier-viewport.has-image img {
  width: 100%; height: 100%; object-fit: contain; position: relative; z-index: 1;
  cursor: zoom-in;
}

/* Plain black loading cover -- replaces the old per-pixel glitch/noise
   reveal on request. Sits over whatever box it's placed in (an image's own
   .dossier-viewport/.image-lightbox-frame, both already position:relative
   -- see app.js's buildImageCover()) showing the same shared spinner
   (.log-spinner, defined above) until the real image has actually loaded,
   then fades away -- no fade-IN of its own, since it's already covering
   the instant its container mounts; there's nothing to fade in FROM. */
.image-cover {
  position: absolute; inset: 0; z-index: 2;
  display: flex; align-items: center; justify-content: center;
  background: var(--bg-0);
  opacity: 1; transition: opacity var(--fade-ms) ease;
  pointer-events: none;
}
.image-cover.fade-hidden { opacity: 0; }

/* Full-screen version of the same black cover, played on initial load of
   either index.html or admin.html, and again just before navigating
   between the two (see playPageReveal()/navigateWithReveal() -- app.js has
   its own copy, admin.html's inline <script> has its own separate copy,
   same duplicate-rather-than-share convention this codebase already uses
   elsewhere), so a trip back and forth between them reads as one
   continuous cover/uncover transition despite being two separate real page
   loads under the hood. Present in the raw HTML from the very first paint,
   already fully opaque via this same CSS (no JS needed for that part), so
   there's never a flash of unstyled content before either page's own
   script has run. z-index above literally everything else on the page
   (modals included) since it's meant to cover the entire screen. */
:root { --fade-ms: 420ms; } /* "medium paced" -- kept in sync with app.js's own FADE_MS */
.page-reveal {
  position: fixed; inset: 0; z-index: 9999;
  display: flex; align-items: center; justify-content: center;
  background: var(--bg-0);
  opacity: 1; transition: opacity var(--fade-ms) ease;
  pointer-events: none;
}
.page-reveal.fade-hidden { opacity: 0; }
.page-reveal.gone { display: none; }
@media (prefers-reduced-motion: reduce) {
  .image-cover, .page-reveal { transition: none; }
}

/* The dossier view breaks out of #app's normal centered max-width (see
   setView() in app.js, which toggles this class) so the two-column layout
   below actually uses the full width of the window instead of leaving
   large empty margins on either side. */
#app.app-wide { max-width: none; }

/* Two-column dossier layout: image roughly 60% of the width on the left,
   text/stats roughly 40% on the right, filling the full width of the
   window instead of one narrow centered column -- and each column a
   genuinely SEPARATE, independently-scrolling region, not one page that
   happens to look like two columns. That takes two things together:
   1) fitDossierColumns() in app.js measures the real space below the nav/
      tab-strip rows and sets an identical inline height on both columns
      (the height/min-height below are only a pre-JS fallback so there's no
      flash of unsized content) -- a hardcoded CSS-only guess drifted out
      of sync with the actual chrome height and left the page itself taller
      than one screen, which is what made it fall back to a single
      page-level scroll (moving both columns together) instead of two
      independent ones.
   2) html.dossier-open/body.dossier-open below removes page-level scrolling
      entirely while the dossier is open, so a wheel/trackpad scroll has
      nowhere to go except whichever of the two bounded, overflow-y:auto
      columns the cursor actually happens to be over.
   Narration auto-scroll (wireTextAutoscroll() in app.js) is wired to
   .dossier-col-text only -- the image/play-bar column only ever moves if
   you scroll it yourself. */
.dossier-columns { display: flex; gap: 34px; }
.dossier-col-image {
  flex: 0 0 60%; max-width: 60%; position: relative;
  display: flex; flex-direction: column;
  height: calc(100vh - 300px); min-height: 360px;
  overflow-y: auto;
}
.dossier-col-text { flex: 1 1 40%; min-width: 0; height: calc(100vh - 300px); min-height: 360px; overflow-y: auto; padding-right: 8px; }
@media (min-width: 901px) {
  html.dossier-open, body.dossier-open { overflow: hidden; }
}
@media (max-width: 900px) {
  .dossier-columns { flex-direction: column; }
  .dossier-col-image, .dossier-col-text { flex: 1 1 auto; max-width: 100%; width: 100%; }
  .dossier-col-image, .dossier-col-text { height: auto; }
  /* aspect-ratio, not a fixed 60vh height -- every dossier image is a
     landscape 16:9 shot (see image_client.py's own image_size), and a flat
     vh height takes no account of the column's actual WIDTH at all. On a
     tall portrait screen (an iPad held upright especially, with a lot of
     vertical room to give away) that fixed height was often noticeably
     taller than a 16:9 image at that width actually needs, so object-fit:
     contain shrank the image down to fit and letterboxed the rest --
     reading as "the image is much smaller than its own box." Sizing by
     aspect-ratio instead means the box's real height always matches the
     image's real proportions at whatever width it has; max-height keeps a
     sane ceiling for an unusually narrow column instead of growing
     unbounded. */
  .dossier-viewport { flex: none; height: auto; aspect-ratio: 16 / 9; max-height: 60vh; min-height: 0; }
  /* Neither column is its own independent scroll container on mobile --
     the whole page scrolls instead (see the stacked layout above) -- but
     both still carried the desktop overflow-y:auto default, which meant
     either one could show its own small internal vertical scrollbar
     whenever its content came out even a hair taller than its box (most
     visibly .dossier-col-image on the Overview tab, once the galaxy
     hologram panel's own margin pushed it past the fixed has-galaxy
     height below). Reset to visible on both, so nothing inside a tab ever
     shows its own scrollbar -- only the one real page-level scroll does.
     Still some extra side padding on the text column, not 0 -- that
     page-level scroll still shows a scrollbar/overlay indicator hugging
     the right edge of the viewport on most mobile browsers, and with zero
     clearance here the text/bar-row content ran right up against it.
     Matching padding on the LEFT too, not just the right -- every text
     element in here (.entry-desc, .stat-row, etc.) centers itself within
     .dossier-col-text's own content box, so right-only padding shifted
     that box's real center (and therefore every centered line inside it)
     visibly left of the column's true center -- reads as "everything's
     slightly too far left," even though text-align:center was working
     exactly as asked the whole time. */
  .dossier-col-image, .dossier-col-text { overflow-y: visible; }
  .dossier-col-text { padding-left: 46px; padding-right: 46px; }

  /* Overview tab only (app.js adds "has-galaxy" to .dossier-col-image
     exactly when it moves the galaxy hologram panel in here -- see
     renderDossier()): the plain 60vh image above gets overridden smaller
     and pinned to the top of the column instead, and the galaxy panel
     -- appended last, after the image and audio row -- grows to fill
     whatever height is left in the column beneath them, rather than
     staying locked to a fixed square. Three.js's own camera/renderer
     already adapt to whatever real width/height the container ends up
     with (see hologram3d.js's onResize), so a non-square fill here is
     never "stretched" the way a flat 2D image would be -- it's just a
     wider or taller field of view on the same real 3D scene. */
  .dossier-col-image.has-galaxy { height: 82vh; }
  .dossier-col-image.has-galaxy .dossier-viewport { height: auto; aspect-ratio: 16 / 9; max-height: 30vh; }
}

/* iPad (or similarly-sized touch tablet) held sideways: width crosses the
   901px threshold that keeps the two-column split (image | text) rather
   than stacking, but the Overview tab's galaxy hologram still gets the
   same "compact image up top, galaxy fills the rest" treatment the
   <=900px stacked layout gets above -- app.js's isCompactGalaxyLayout()
   decides when to move the panel into .dossier-col-image (see
   .hologram-panel--below-image); this just supplies the sizing once it's
   there, since .dossier-col-image itself keeps its normal desktop height
   (JS-measured via fitDossierColumns) rather than the mobile 82vh above.
   Scoped to touch + landscape + a tablet-ish width band so an ordinary
   mouse-driven desktop/laptop window at the same width is untouched. */
@media (min-width: 901px) and (max-width: 1400px) and (orientation: landscape) and (pointer: coarse) {
  .dossier-col-image.has-galaxy .dossier-viewport { flex: 0 0 auto; height: 30vh; min-height: 0; }
  /* .dossier-col-text is still its own independently-scrolling column here
     (only the <=900px stacked layout resets that to a page-level scroll),
     so it still shows its own scroll/overlay indicator on the right edge
     -- same clearance problem the portrait stacked layout already got
     fixed for. Matching padding-left too, same reason as that fix's own
     comment: right-only padding shifts every centered text element's real
     center left of the column's true center. */
  .dossier-col-text { padding-left: 46px; padding-right: 46px; }
}

/* Small "⟳" regenerate button -- overlaid top-right on the image viewport
   (see .regen-icon--overlay). Queues one more independent attempt at that
   image (same prompt/reference, new roll) for right after the next archive
   finishes generating. The audio equivalent (regenAudioButton) reuses this
   same class/behavior but now lives inside .audio-menu-popover below,
   relabeled "RELOAD" -- see .audio-menu-reload. */
.regen-icon {
  width: 24px; height: 24px; border-radius: 50%; flex-shrink: 0;
  border: 1px solid var(--panel-border); background: rgba(20,32,44,0.75); color: var(--cyan-dim);
  cursor: pointer; padding: 0; display: inline-flex; align-items: center; justify-content: center;
  font-size: 0.95em; line-height: 1; transition: all 0.15s ease;
}
.regen-icon:hover:not(.disabled) { color: var(--cyan); border-color: var(--cyan); box-shadow: 0 0 8px rgba(95,227,255,0.3); }
.regen-icon.disabled { color: var(--text-faint); cursor: not-allowed; opacity: 0.55; }
.regen-icon--overlay { position: absolute; top: 7px; right: 7px; z-index: 2; }

/* Three-dot audio menu -- sits at the end of the top-nav progress bar (see
   .nav-progress-track and buildAudioMenu() in app.js). Replaces the old
   plain box underneath the image: no visible box/icons live there anymore
   at all, every audio control (volume, mute, regenerate) is tucked behind
   this one button instead. */
.audio-menu { position: relative; display: inline-flex; flex: 0 0 auto; }
.audio-menu-btn {
  cursor: pointer; color: var(--cyan-dim); border: 1px solid var(--panel-border);
  border-radius: 50%; width: 22px; height: 22px; padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 0.9em; line-height: 1; background: rgba(20,32,44,0.6);
  font-family: var(--sans); transition: all 0.15s ease;
}
.audio-menu-btn:hover, .audio-menu-btn:focus-visible {
  color: var(--cyan); border-color: var(--cyan); outline: none;
  box-shadow: 0 0 8px rgba(95,227,255,0.25);
}

/* The popover itself -- a top glow line (the same motif every panel in
   this app opens with, see .dossier-panel::before/.hologram-panel--below-
   image::before) plus a subtle depth gradient and layered shadow, rather
   than one flat solid-color box, is what separates this from reading as a
   plain UI-kit dropdown. */
.audio-menu-popover {
  position: absolute; top: calc(100% + 10px); right: 0; z-index: 20;
  display: flex; flex-direction: column; gap: 14px; min-width: 220px;
  padding: 16px 18px; border-radius: 10px;
  background: linear-gradient(165deg, rgba(24,38,50,0.97), rgba(9,15,20,0.98));
  border: 1px solid var(--panel-border);
  box-shadow: 0 14px 34px rgba(0,0,0,0.55), 0 0 0 1px rgba(95,227,255,0.05) inset;
  overflow: hidden;
}
.audio-menu-popover::before {
  content: ""; position: absolute; top: 0; left: 0; right: 0; height: 2px;
  background: linear-gradient(90deg, transparent, var(--cyan), transparent);
  opacity: 0.7;
}
.audio-menu-popover.hidden { display: none; }

.audio-menu-section { display: flex; flex-direction: column; gap: 8px; }
.audio-menu-label {
  font-size: 0.95em; letter-spacing: 1.5px; text-transform: uppercase;
  color: var(--cyan-dim); font-family: var(--mono); font-weight: 600;
}
.audio-menu-divider { height: 1px; background: var(--panel-border); opacity: 0.7; }

/* A custom track/thumb instead of the bare browser-default range input --
   the flat OS-native slider (fine on the Settings page, sitting among
   other plain form controls) read as noticeably out of place inside this
   otherwise glow/gradient-styled popover. */
.audio-menu-popover input[type=range] {
  -webkit-appearance: none; appearance: none; flex: 1; width: auto;
  height: 4px; border-radius: 2px; background: rgba(255,255,255,0.08);
  outline: none; cursor: pointer;
}
.audio-menu-popover input[type=range]::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none;
  width: 13px; height: 13px; border-radius: 50%; background: var(--cyan);
  box-shadow: 0 0 0 3px rgba(95,227,255,0.16), 0 0 8px rgba(95,227,255,0.6);
  cursor: pointer;
}
.audio-menu-popover input[type=range]::-moz-range-track {
  height: 4px; border-radius: 2px; background: rgba(255,255,255,0.08);
}
.audio-menu-popover input[type=range]::-moz-range-thumb {
  width: 13px; height: 13px; border-radius: 50%; border: none; background: var(--cyan);
  box-shadow: 0 0 0 3px rgba(95,227,255,0.16), 0 0 8px rgba(95,227,255,0.6);
  cursor: pointer;
}

.audio-menu-reload {
  width: 100%; height: auto; border-radius: 6px; padding: 10px 12px;
  display: flex; align-items: center; justify-content: center; gap: 8px;
  font-size: 0.72em; font-family: var(--mono); letter-spacing: 1.5px; text-transform: uppercase;
  background: rgba(95,227,255,0.05); border-color: var(--panel-border);
}
.audio-menu-reload:hover:not(.disabled) {
  background: rgba(95,227,255,0.1); color: var(--cyan); border-color: var(--cyan);
  box-shadow: 0 0 10px rgba(95,227,255,0.25);
}
.audio-menu-reload-icon { font-size: 1.2em; line-height: 1; }

/* ---------- Phase 6: the one surviving hologram -- a small spinning
   galaxy map with a red position marker, Overview tab only, sitting in the
   Coordinates section's right half (see .coord-row below). See app.js's
   renderDossier()/renderBaseEntry(). */
.hologram-panel {
  border-radius: 6px; background: rgba(0,0,0,0.2);
  border: 1px solid var(--panel-border); position: relative; flex: 0 0 auto;
  /* Column, not the old single-box square -- .hologram-render-box below
     carries the square/clipped-overflow look that used to live directly on
     this element; that leaves room for .hologram-legend to sit underneath
     the render itself rather than being squeezed inside the same fixed
     aspect-ratio box (or clipped by the overflow:hidden that box needs). */
  display: flex; flex-direction: column;
}
/* The actual square render, clipped -- carries what .hologram-panel itself
   used to be a single box of. aspect-ratio:1/1 here is the DESKTOP/default
   sizing (the small fixed Coordinates-section box, and anywhere else this
   class is used un-overridden) -- the below-image mobile/tablet variant
   overrides this back to a flexible, non-square fill instead (see
   .hologram-panel--below-image .hologram-render-box further down), since a
   width-derived square is only ever safe when the container has ROOM below
   it for that same height -- never guaranteed once the panel's own width
   and the viewport's actual height stop tracking each other, which is
   exactly what a landscape phone does. */
.hologram-render-box { width: 100%; aspect-ratio: 1 / 1; overflow: hidden; border-radius: 6px; position: relative; flex: 0 0 auto; }
/* touch-action:none hands one-finger drag / pinch entirely to
   OrbitControls (see hologram3d.js) instead of letting the browser's own
   native scroll/pinch-zoom gestures fight it for the same touch input --
   the standard recommendation for an embedded interactive 3D/map widget. */
.hologram-canvas { width: 100%; height: 100%; touch-action: none; }
/* Legend for the two fixed marker colors on the render -- see
   hologram2d.js's own red ("this region") / green ("Sol", drawn only on a
   Milky Way map) dots. Scientific-code labels, not flavor names: the
   region's own real catalog_code, and "Sol" for the fixed Earth/Sun
   marker, matching the request to label these the same way the rest of
   the dossier identifies things formally. */
.hologram-legend {
  display: flex; gap: 14px; justify-content: center; flex-wrap: wrap;
  padding: 8px 4px 2px; font-family: var(--mono); font-size: 0.68em; letter-spacing: 0.5px;
  color: var(--text-dim);
}
.hologram-legend-item { display: inline-flex; align-items: center; gap: 6px; }
.hologram-legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
.hologram-legend-dot--red { background: var(--danger); box-shadow: 0 0 4px var(--danger); }
.hologram-legend-dot--green { background: var(--ok); box-shadow: 0 0 4px var(--ok); }
/* "Origin" -- top-left of the panel, relatively tiny font on request. The
   one way back to the starting zoom/pitch/pan now that it no longer resets
   itself automatically after 5s idle (see hologram2d.js's own mode
   state-machine comment) -- switching tabs is the other, since a fresh
   mount always starts at the defaults regardless. */
.hologram-origin-btn {
  position: absolute; top: 6px; left: 6px; z-index: 3;
  font-size: 0.6em; padding: 3px 8px; border-radius: 3px;
  background: rgba(20,32,44,0.75); border: 1px solid var(--panel-border); color: var(--cyan-dim);
  cursor: pointer; font-family: var(--mono); letter-spacing: 0.5px; text-transform: uppercase;
  transition: all 0.15s ease;
}
.hologram-origin-btn:hover, .hologram-origin-btn:focus-visible { color: var(--cyan); border-color: var(--cyan); outline: none; }

.entry-code { font-family: var(--mono); font-size: 0.72em; color: var(--text-faint); letter-spacing: 1px; text-align: center; }
.entry-title { font-size: 1.7em; font-weight: 700; margin: 4px 0 3px; color: var(--text); }
/* No rename/delete icon ever sits in this row anymore -- both moved out to
   the home grid's own cards (see .card-name-row below). Still 1fr / auto /
   1fr rather than a plain centered block, purely so .entry-title (targeted
   by class, not position) always lands in the true center column no matter
   what -- kept simple/robust rather than collapsing to a single column now
   that there's nothing else to ever share the row with. */
.entry-title-row { display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; column-gap: 8px; }
.entry-title-row .entry-title { grid-column: 2; margin: 4px 0 3px; text-align: center; }
.rename-icon {
  width: 18px; height: 18px; border-radius: 50%; flex-shrink: 0; padding: 0;
  border: 1px solid var(--panel-border); background: transparent; color: #ffffff;
  cursor: pointer; display: inline-flex; align-items: center; justify-content: center;
  font-size: 0.62em; line-height: 1; transition: all 0.15s ease; opacity: 0.75;
}
.rename-icon:hover { color: var(--cyan); border-color: var(--cyan); opacity: 1; }
/* Gold title color is scoped to THIS dossier tab area only -- never
   applied to a home-grid .card-name, which stays its normal color
   regardless of favorited state. Toggled by renderDossier() directly on
   the one .entry-title element the currently-open tab rendered. */
.entry-title.favorited { color: var(--amber); text-shadow: 0 0 10px rgba(255,180,84,0.35); }
.entry-subtitle { font-family: var(--mono); font-size: 0.82em; color: var(--cyan-dim); margin: 10px 0 16px; text-align: center; }

/* Headless now -- app.js's audioPlayer() no longer sets the `controls`
   attribute at all, since the only visible playback UI left anywhere in
   the dossier is the top-nav play button + progress bar (see
   .nav-progress-track below and wireNavProgressBar() in app.js). This
   element still does the actual playing, just with nothing of its own to
   draw, so there's no reason to reserve visual space for it. */
.va-audio { display: none; }

/* Plain, non-interactive display chips -- e.g. recurring weather/phenomena.
   No click behavior lives on individual items anymore; a section's own
   (i) info icon (see .info-icon below) explains everything in it at once. */
.info-chips { display: flex; flex-wrap: wrap; justify-content: center; gap: 7px; margin-bottom: 20px; }
.info-chip {
  font-size: 0.78em; padding: 4px 11px; border-radius: 14px; border: 1px solid var(--panel-border);
  color: var(--text-dim); background: rgba(255,255,255,0.04); display: inline-block;
}
.info-chip.exotic { border-color: var(--amber); color: var(--amber); background: rgba(255,180,84,0.06); }

/* The small circular (i) button that sits next to a subsection title and
   opens the Field Guide modal with definitions for everything below it. */
.info-icon {
  display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px;
  border-radius: 50%; border: 1px solid var(--cyan-dim); color: var(--cyan-dim); background: transparent;
  font-size: 0.68em; font-style: italic; font-family: Georgia, 'Times New Roman', serif; line-height: 1;
  cursor: pointer; padding: 0; flex-shrink: 0;
}
.info-icon:hover { color: var(--cyan); border-color: var(--cyan); box-shadow: 0 0 6px rgba(95,227,255,0.35); }

.glossary-card { width: 640px; max-width: 92vw; max-height: 74vh; display: flex; flex-direction: column; }
.glossary-kicker { font-size: 0.7em; letter-spacing: 1.5px; text-transform: uppercase; color: var(--cyan-dim); margin-bottom: 4px; }
#glossary-body { overflow-y: auto; margin: 4px 0 0; padding-right: 4px; }
.glossary-entry { padding: 12px 0; border-bottom: 1px solid rgba(255,255,255,0.06); }
.glossary-entry:last-child { border-bottom: none; }
.glossary-term { color: var(--cyan); font-weight: 600; font-size: 0.9em; margin-bottom: 4px; }
.glossary-def { line-height: 1.6; color: var(--text-dim); font-size: 0.9em; }

/* iPad portrait only (see isTabletPortraitReorder() in app.js), every tab:
   the moved favorite icon/title/code/subtitle cluster, sitting above the
   image column instead of at the top of the text column. Every element
   inside it keeps its own normal styling (.entry-title-row,
   .entry-subtitle, etc. are none of them scoped to .dossier-col-text) --
   this only adds the breathing room a plain child of .dossier-columns
   wouldn't otherwise have below it, before the image. */
.entry-header-block { margin-bottom: 8px; }

/* "Justify" the same way a word processor's Justify setting does -- every
   full line stretches to fill the column edge to edge, except the very
   last line of the block, which (the one deliberate difference from a
   plain word-processor Justify, where that last line stays left-aligned)
   is centered instead. text-align-last handles a genuinely single-line
   block the same way -- there's no other line to justify against, so it
   just reads as centered, matching this app's existing short-text
   centering convention everywhere else. */
.entry-desc { line-height: 1.7; color: var(--text-blue); font-size: 0.97em; margin-bottom: 26px; text-align: justify; text-align-last: center; }
.entry-desc .rationale { color: var(--text-dim); font-style: italic; display: block; margin-bottom: 10px; text-align: justify; text-align-last: center; }
.entry-para { line-height: 1.7; color: var(--text-blue); font-size: 0.97em; margin-bottom: 14px; text-align: justify; text-align-last: center; }
.entry-para:last-child { margin-bottom: 26px; }

/* Coordinates -- deliberately small/quiet. Left column: Galaxy, then X/Y/Z,
   then the two derived polar-form figures (Galactic Radius/Bearing), all
   stacked in a single column (not the usual 2-col .stat-grid), narrower
   than the galaxy hologram since that's the visual centerpiece of the row,
   not an equal-width afterthought. Right column: the spinning galaxy
   hologram, deliberately framed like the rest of the app's "active panel"
   language instead of floating unexplained next to the numbers -- a
   top-edge cyan glow line (same motif as .dossier-panel::before) and a
   connecting divider against the coordinate list (border-right below)
   tying the two halves into one row. Nudged a hair right of dead-center
   via margin-left so it doesn't read as flush-glued to that divider. */
.coord-row { display: flex; gap: 18px; align-items: stretch; margin-bottom: 22px; }
.coord-row .coord-list {
  flex: 1 1 34%; display: flex; flex-direction: column; justify-content: center;
  min-width: 0; padding-right: 18px; border-right: 1px solid var(--panel-border);
}
.coord-row .coord-list .stat-row { padding: 5px 0; }
.coord-row .hologram-panel--galaxy {
  flex: 1 1 66%; width: 100%; max-width: 340px; aspect-ratio: 1 / 1;
  position: relative; margin-left: 10px;
  box-shadow: 0 0 22px rgba(95,227,255,0.08) inset;
}
.coord-row .hologram-panel--galaxy::before {
  content: ""; position: absolute; top: 0; left: 0; right: 0; height: 2px;
  background: linear-gradient(90deg, transparent, var(--cyan), transparent);
  opacity: 0.7; z-index: 1; border-radius: 6px 6px 0 0;
}
@media (max-width: 720px) {
  .coord-row { flex-direction: column; }
  .coord-row .coord-list { border-right: none; padding-right: 0; border-bottom: 1px solid var(--panel-border); padding-bottom: 14px; }
}
/* On any mobile/tablet layout -- portrait or landscape -- the galaxy
   hologram is moved out of .coord-row entirely (see the
   hologram-panel--below-image move in app.js), leaving coord-list alone in
   what used to be a two-part row. Its own divider (border-right above
   720px, border-bottom below it) existed only to separate it from the
   hologram sitting right beside/below it -- with nothing there anymore,
   the line just orphans itself, so it's removed here for every mobile/
   tablet width, matching isCompactGalaxyLayout()'s own two width bands. */
@media (max-width: 900px), (min-width: 901px) and (max-width: 1400px) and (orientation: landscape) and (pointer: coarse) {
  .coord-row .coord-list { border-right: none; border-bottom: none; padding-right: 0; padding-bottom: 0; }
}

/* Mobile (<=900px): app.js moves the galaxy hologram panel out of
   .coord-row and into .dossier-col-image, below the (now compact) overview
   image -- see the "hologram-panel--below-image" move in renderDossier()
   and .dossier-col-image.has-galaxy above. Reset the desktop
   .coord-row-scoped sizing (max-width:340px, a left margin meant to offset
   it from the coordinate list, a forced square) since none of that applies
   once it's a plain block sitting under the image: full column width, and
   flex:1 so it grows to fill whatever height .dossier-col-image.has-galaxy
   leaves once the compact image and audio row above it take their share --
   never a stretched image, since this is a live 3D camera, not a flat
   picture (see the has-galaxy comment above). */
.hologram-panel--below-image {
  width: 100%; max-width: 100%; margin: 14px 0 0;
  flex: 1 1 auto; min-height: 160px; min-width: 0;
  box-shadow: 0 0 22px rgba(95,227,255,0.08) inset;
}
/* Overrides .hologram-render-box's own unconditional aspect-ratio:1/1 (see
   its own comment above) for JUST this below-image variant -- a landscape
   phone is far WIDER than it is tall (e.g. ~850px wide but only ~390px of
   real viewport height to work with), and a square derived from that width
   alone comes out taller than the entire screen, spilling straight through
   whatever's below it on the page ("meshed through everything outside the
   box"). Filling the panel's own flex-allocated rectangle instead (bounded
   by min-height:0 so it can actually shrink to fit, same flexbox gotcha
   .hologram-panel--below-image itself needs min-width:0 for above) lets it
   go non-square there -- hologram2d.js's own render already scales off
   Math.min(w, h), so a wide-short or tall-narrow box never stretches or
   distorts the content, it's just a wider or taller field of view on the
   same scene, same as this panel already did before the legend row (and
   the aspect-ratio fix that came with it) was added. */
.hologram-panel--below-image .hologram-render-box {
  aspect-ratio: auto; flex: 1 1 auto; min-height: 0; height: auto;
}
.hologram-panel--below-image::before {
  content: ""; position: absolute; top: 0; left: 0; right: 0; height: 2px;
  background: linear-gradient(90deg, transparent, var(--cyan), transparent);
  opacity: 0.7; z-index: 1; border-radius: 6px 6px 0 0;
}

.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 28px; margin-bottom: 8px; }
@media (max-width: 720px) { .stat-grid { grid-template-columns: 1fr; } }

.stat-row { padding: 8px 0; border-bottom: 1px solid rgba(255,255,255,0.05); text-align: center; }
.stat-row .stat-label { font-size: 0.72em; letter-spacing: 1px; color: #ffffff; text-transform: uppercase; }
.stat-row .stat-value { font-family: var(--mono); font-size: 0.92em; color: var(--text-blue); margin-top: 2px; }
/* Two-word labels (Body Classification, Primary Name, Origin Subclass,
   Estimated Age -- see twoLineLabel() in app.js) that opt into this: both
   words stay on one line normally, the second word dropping directly under
   the first (each line centered) ONLY on a touch/mobile device held in
   LANDSCAPE -- e.g. an iPad turned sideways, where the two-column dossier
   layout keeps the text column active (see isCompactGalaxyLayout's own
   901-1400px/landscape/coarse-pointer band in app.js) and its real width
   ends up narrower than the SAME device held upright, where the columns
   stack instead and the text column gets the device's full width. Portrait
   ("held right side up") deliberately stays normal/one-line, on request --
   this is NOT a plain narrow-viewport check.
   .stat-label-split itself MUST be display:block -- left as the default
   inline (a bare <span>), its two children below flipping to display:block
   puts block-level boxes inside an inline parent, which browsers don't
   render as a clean two-line stack (undefined/inconsistent "block in
   inline" box-fixup behavior) -- this is what silently broke the split the
   first time. */
.stat-label-split { display: block; }
.stat-label-split-line { display: inline; }
.stat-label-split-line:first-child { margin-right: 0.35em; }
@media (orientation: landscape) and (pointer: coarse) and (max-width: 1400px) {
  .stat-label-split-line { display: block; }
  .stat-label-split-line:first-child { margin-right: 0; }
}
.stat-row .stat-value.not-present { color: var(--text-faint); font-style: italic; }

.bar-row { margin: 10px 0; }
.bar-row .bar-label { display: flex; justify-content: space-between; font-size: 0.75em; color: var(--text-dim); margin-bottom: 4px; }
.bar-track { height: 8px; border-radius: 4px; background: rgba(255,255,255,0.06); overflow: hidden; }
.bar-fill { height: 100%; border-radius: 4px; background: linear-gradient(90deg, var(--cyan-dim), var(--cyan)); box-shadow: 0 0 8px rgba(95,227,255,0.5); transition: width 0.4s ease; }
.bar-fill.danger { background: linear-gradient(90deg, #b3403f, var(--danger)); box-shadow: 0 0 8px rgba(255,95,109,0.5); }
.bar-fill.amber { background: linear-gradient(90deg, #a8752c, var(--amber)); box-shadow: 0 0 8px rgba(255,180,84,0.5); }
.bar-fill.empty { background: repeating-linear-gradient(45deg, rgba(255,255,255,0.05) 0 4px, transparent 4px 8px); box-shadow: none; }

/* Grid, not a plain centered flex row -- same reasoning as .entry-title-row
   above: a flex row centers the LABEL+ICON group together, which drags the
   label itself off true center whenever the optional (i) info icon (see
   sectionTitle() in app.js) is present. The two 1fr columns balance around
   the label regardless of whether that icon column is empty or occupied. */
/* Two sizes up from 0.72em on request -- this one class covers every
   subsection heading across every tab (Coordinates, Classification,
   Description, Physical Profile, Atmosphere Composition, Environmental
   Profile, Natural/Geological Resources, Soil/Earth Composition, Field
   Notes, Plausibility Note, Tissue Sample/Structure Composition/Collected
   Sample, etc. -- anything built via sectionTitle() or descriptionTitleRow()
   in app.js), so bumping it here changes all of them at once. */
.subsection-title {
  font-size: 0.92em; letter-spacing: 2px; color: #ffffff; text-transform: uppercase; margin: 26px 0 12px;
  display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; column-gap: 8px;
}
.subsection-title > span:first-child { grid-column: 2; text-align: center; }
.subsection-title > .info-icon { grid-column: 3; justify-self: start; }
/* Radiation & Hazard opts back out of the size-up above, on request --
   the original 0.72em, everything else about .subsection-title unchanged. */
.subsection-title--normal-size { font-size: 0.72em; }

/* ---------- classification codex ---------- */
.codex-system {
  background: var(--panel); border: 1px solid var(--panel-border); border-radius: 8px;
  padding: 22px 26px; margin-bottom: 18px;
}
.codex-system h3 { margin: 0 0 4px; color: var(--cyan); font-size: 1.05em; }
.codex-meta { font-size: 0.75em; color: var(--text-faint); margin-bottom: 14px; font-family: var(--mono); }
.codex-desc-input {
  width: 100%; background: rgba(0,0,0,0.25); border: 1px solid var(--panel-border); color: var(--text-dim);
  font-size: 0.85em; padding: 8px 10px; border-radius: 4px; margin-bottom: 16px; font-family: var(--sans);
  resize: vertical; overflow-y: hidden;
}
.codex-class-list { display: flex; flex-direction: column; gap: 10px; }
.codex-class-row {
  display: grid; grid-template-columns: 200px 1fr 140px; gap: 12px; align-items: start;
  padding: 10px; border: 1px solid rgba(255,255,255,0.05); border-radius: 5px; background: rgba(0,0,0,0.15);
}
@media (max-width: 900px) {
  .codex-class-row { grid-template-columns: 1fr; }
}
.codex-class-row input[type=text], .codex-class-row textarea, .codex-class-row input[type=number] {
  width: 100%; background: rgba(0,0,0,0.3); border: 1px solid var(--panel-border); color: var(--text);
  padding: 7px 9px; border-radius: 4px; font-family: var(--sans); font-size: 0.85em;
}
/* No fixed min-height -- app.js's autosizeTextarea() grows every criteria
   box to fit its own full text the instant the Codex renders (and again on
   every edit), so a long description (several sentences is common) is
   completely visible with no inner scrollbar, on desktop or mobile alike.
   resize:vertical stays available too, purely as a manual override. */
.codex-class-row textarea { min-height: 44px; resize: vertical; overflow-y: hidden; }
.codex-class-row .field-tag { font-size: 0.65em; color: var(--text-faint); margin-top: 4px; font-family: var(--mono); }
.codex-subclasses { grid-column: 1 / -1; padding-left: 14px; border-left: 2px solid var(--panel-border); margin-top: 4px; }
.codex-subclasses .sub-item { font-size: 0.78em; color: var(--text-dim); padding: 4px 0; }
.codex-subclasses .sub-item b { color: var(--text); }

.codex-actions { display: flex; gap: 10px; margin: 22px 0 40px; }

/* ---------- settings ---------- */
/* A grouped-list settings page (like a native OS Settings app) instead of
   several separate bordered "boxes" stacked on top of each other -- one
   small uppercase .settings-group-title, then one rounded .settings-list
   holding thin-divided .settings-row children (label(+description) on the
   left via .settings-row-text, its control on the right via
   .settings-row-control). Only the Power Control group gets its own visual
   weight (a red-tinted border) since it's the one section worth a beat of
   hesitation before touching. */
.settings-page { max-width: 720px; }
.settings-group { margin-bottom: 28px; }
/* Archive Generation and Power Control sit side by side (see renderSettings()
   in app.js) rather than stacked -- each takes half the row on a wide
   enough screen, and drops back to stacked full-width the moment there
   isn't room for both, same breakpoint the rest of the app uses for
   "mobile." align-items:stretch (the flex default, stated explicitly here)
   plus each group laid out as its own flex column with its .settings-list
   set to flex:1 is what actually makes the two boxes match height -- one
   short list (Power Control's single row) and one longer list (Archive
   Generation's two rows) would otherwise each just be exactly as tall as
   their own content. Extra margin-bottom beyond the standard 28px other
   groups use, so Automatic Schedule below reads as clearly its own
   section, not crowded right up against this row. */
.settings-row-groups { display: flex; gap: 20px; align-items: stretch; margin-bottom: 40px; }
.settings-row-groups > .settings-group {
  flex: 1 1 0; min-width: 0; margin-bottom: 0;
  display: flex; flex-direction: column;
}
.settings-row-groups > .settings-group .settings-list { flex: 1; }
@media (max-width: 720px) {
  .settings-row-groups { flex-direction: column; gap: 0; }
  .settings-row-groups > .settings-group { margin-bottom: 28px; }
}
.settings-group-title {
  font-size: 0.72em; letter-spacing: 2px; text-transform: uppercase; color: var(--cyan-dim);
  margin: 0 0 10px; padding-left: 2px;
}
.settings-group.danger .settings-group-title { color: var(--danger); }
.settings-list {
  background: var(--panel); border: 1px solid var(--panel-border); border-radius: 10px;
  overflow: hidden;
}
.settings-group.danger .settings-list { border-color: var(--danger); }
.settings-row {
  display: flex; align-items: center; justify-content: space-between; gap: 18px;
  padding: 14px 18px; border-bottom: 1px solid rgba(255,255,255,0.06);
}
.settings-row:last-child { border-bottom: none; }
.settings-row-text { flex: 1 1 auto; min-width: 0; }
.settings-row-label { font-size: 0.88em; color: var(--text); font-weight: 500; }
.settings-row-desc { font-size: 0.76em; color: var(--text-dim); margin-top: 3px; line-height: 1.5; }
.settings-row-control { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; }
.settings-row-btn { padding: 8px 14px; font-size: 0.78em; }
.settings-group-actions { display: flex; gap: 10px; margin-top: 12px; }
.settings-group-desc { font-size: 0.76em; color: var(--text-faint); line-height: 1.6; margin-top: 10px; padding: 0 2px; }

/* ---------- settings: scheduling ---------- */
.schedule-days { display: flex; flex-wrap: wrap; gap: 6px; justify-content: flex-end; }
.day-toggle { padding: 6px 11px; font-size: 0.76em; }
#schedule-time, #schedule-count { width: auto; }

.volume-control { display: flex; align-items: center; gap: 12px; }
.volume-control .mute-btn {
  cursor: pointer; color: var(--text-dim); border: 1px solid var(--panel-border); border-radius: 4px;
  width: 34px; height: 34px; flex-shrink: 0; padding: 0;
  display: inline-flex; align-items: center; justify-content: center;
  font-size: 1em; background: rgba(20,32,44,0.6); transition: all 0.15s ease;
}
.volume-control .mute-btn:hover, .volume-control .mute-btn:focus-visible { color: var(--cyan); border-color: var(--cyan); outline: none; }
.volume-control .mute-btn.muted { color: var(--danger); border-color: var(--danger); }
.volume-control input[type=range] { flex: 1; width: auto; padding: 0; accent-color: var(--cyan); }

/* ---------- generic ---------- */
.field-label { display: block; font-size: 0.75em; letter-spacing: 1px; color: var(--text-dim); margin: 14px 0 6px; text-transform: uppercase; }
input[type=text], input[type=range], input[type=time], input[type=number] { background: rgba(0,0,0,0.3); border: 1px solid var(--panel-border); color: var(--text); padding: 9px 11px; border-radius: 4px; width: 100%; font-family: var(--sans); }
input[type=range] { padding: 0; }
input[type=time]::-webkit-calendar-picker-indicator { filter: invert(0.85) sepia(1) saturate(3) hue-rotate(150deg); cursor: pointer; }

.modal { position: fixed; inset: 0; background: rgba(2,4,6,0.7); backdrop-filter: blur(3px); display: flex; align-items: center; justify-content: center; z-index: 200; }
.modal.hidden { display: none; }
.modal-card { background: var(--bg-1); border: 1px solid var(--cyan-dim); border-radius: 8px; padding: 28px 30px; width: 420px; max-width: 90vw; box-shadow: 0 0 40px rgba(95,227,255,0.15); }
.modal-card h2 { margin: 0 0 6px; font-size: 1.1em; color: var(--cyan); }
.modal-actions { display: flex; gap: 10px; margin-top: 18px; }

/* Tap/click the dossier's main image to open it here, near-fullscreen.
   Deliberately its own overlay rather than reusing .modal -- .modal-card
   is a fixed-width card meant for text/forms; this needs the image itself
   to size up to fill most of the viewport while staying within it (never
   cropped, never upscaled past its own natural size -- see max-width/
   max-height + object-fit below). Sits above every .modal (z-index 250 >
   200) since it's reachable from inside the dossier panel, itself just
   page content, not a modal. Crucially: this is ONLY a visual overlay --
   nothing here ever touches the <audio> element back in the dossier, so
   whatever narration is already playing keeps playing, unpaused, behind
   it, exactly as if the overlay were a sheet of glass laid on top of the
   same page rather than a new view replacing it. */
.image-lightbox {
  position: fixed; inset: 0; background: rgba(2,4,6,0.88); z-index: 250;
  display: flex; align-items: center; justify-content: center; padding: 24px;
  cursor: zoom-out;
}
.image-lightbox.hidden { display: none; }
/* Shrink-wraps to the img's own rendered box (inline-block + no size of
   its own) so the .image-cover overlay app.js attaches here on open lines
   up with the image exactly -- see .image-cover's position:absolute;
   inset:0, which needs a position:relative ancestor sized to match. */
.image-lightbox-frame { position: relative; display: inline-block; line-height: 0; cursor: default; }
.image-lightbox-frame img {
  max-width: 92vw; max-height: 92vh; width: auto; height: auto;
  object-fit: contain; border-radius: 6px; box-shadow: 0 0 60px rgba(95,227,255,0.15);
  display: block;
}
/* Mobile, held upright: every dossier image is landscape (16:9 -- see
   image_client.py), so at a plain max-width:92vw/max-height:92vh on a
   portrait screen the image ends up small and width-bound, with a lot of
   empty overlay above and below it. Rotating the whole frame (img + its
   loading cover together, so the black-cover fade stays aligned to the
   image instead of covering its old, un-rotated footprint) 90° and
   swapping which viewport dimension it's allowed to use as its "width"
   lets it fill the screen the way it would in landscape, on request.
   Landscape orientation is untouched -- the image is already sized
   generously there with nothing to fix. */
@media (max-width: 900px) and (orientation: portrait) and (pointer: coarse) {
  .image-lightbox-frame img { max-width: 92vh; max-height: 92vw; }
  .image-lightbox-frame { transform: rotate(90deg); }
}

.toast {
  position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%);
  background: var(--bg-1); border: 1px solid var(--cyan); color: var(--cyan);
  padding: 11px 20px; border-radius: 5px; font-size: 0.85em; z-index: 300;
  box-shadow: 0 0 20px rgba(95,227,255,0.2);
}
.toast.hidden { display: none; }
.toast.error { border-color: var(--danger); color: var(--danger); }

::selection { background: rgba(95,227,255,0.3); }
