/* dark is the default and does NOT follow OS prefers-color-scheme — light is opt-in only, toggled via #themeToggle and remembered in localStorage. */
:root {
  --xuan: #17181a;
  --xuan-deep: #1f2120;
  --ink: #e8e2d2;
  --dai: #8fa6a0;
  --qing: #7fa98c;
  --zhusha: #c5544a;
  --cloud: #3a3d38;
  --sky-top: #202422;
  --sky-bottom: #14100f;
  --shadow: rgba(0, 0, 0, 0.45);
  /* Separate from --shadow: that 0.45 compounds too dark (5 stacked box-shadow layers), so this
     uses 0.18 to match the light theme's edge alpha (~31% vs ~25%) instead of overshooting to ~63%. */
  --frame-shadow: rgba(0, 0, 0, 0.18);
  --peak-far: #4a5652;
  --peak-mid: #232b28;
  --peak-near: #0b0a09;
  --mist: #6e7873;
  --hero-text: #f5efe1;
  --hero-muted: rgba(245, 239, 225, 0.7);
  --seal-glyph: #f5efe1;
  /* single source for the moonlight tone, shared by moonGradSm and .moon's gradient so they can't drift apart. */
  --moon-glow: #d7e1dc;
  --rim-stroke-sm: url(#moonGradSm);
  /* the moonlit ridge-tip glint already reads as "loading" on its own, so the breathing animation stays paused to avoid stacking two tells. */
  --breathe-play: paused;
  --breathe-anim: none;
  --ridge-anim: none;
  --font-display: "Kaiti SC", "STKaiti", "KaiTi", "Baoli SC", serif;
  --font-body: "Songti SC", "STSong", "SimSun", "Noto Serif SC", serif;
  --font-ui:
    "PingFang SC", "Microsoft YaHei", "Heiti SC", "Helvetica Neue", Arial,
    sans-serif;
}
:root[data-theme="light"] {
  --xuan: #ede6d8;
  --xuan-deep: #e4dbc8;
  --ink: #23231f;
  --dai: #46595a;
  --qing: #5c7a6e;
  --zhusha: #a13b2e;
  --cloud: #c9c2b3;
  --sky-top: #e9e1d1;
  --sky-bottom: #d8cdb8;
  --shadow: rgba(35, 35, 31, 0.14);
  --frame-shadow: rgba(35, 35, 31, 0.14);
  --peak-far: #9fb0ac;
  --peak-mid: #5c7268;
  --peak-near: #23231f;
  --mist: #ede6d8;
  --rim-stroke-sm: none;
  /* daylight has no moonlit-glint tell, so the breathing animation runs instead. */
  --breathe-play: running;
}

* {
  box-sizing: border-box;
}
html,
body {
  height: 100%;
}
body {
  position: relative;
  margin: 0;
  background: var(--xuan);
  color: var(--ink);
  font-family: var(--font-body);
  line-height: 1.6;
  overflow: hidden;
  transition:
    background 0.4s ease,
    color 0.4s ease;
}
a {
  color: inherit;
}
h1,
h2,
h3 {
  margin: 0;
  font-weight: 500;
}
ul {
  list-style: none;
  margin: 0;
  padding: 0;
}
section {
  padding: clamp(3rem, 8vw, 6rem) clamp(1.25rem, 5vw, 4rem);
}

.paper-grain {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 400;
  pointer-events: none;
  opacity: 0.06;
  mix-blend-mode: screen;
}
:root[data-theme="light"] .paper-grain {
  opacity: 0.05;
  mix-blend-mode: multiply;
}

/* ---------- Hero ---------- */
/* hero and .content-panel are two full-viewport panels stacked via position:absolute (see body's
   position:relative above) — the CTA/back button toggle body.content-active. Rather than sliding
   past each other, both zoom from/to the same screen-space point (VISTA_ORIGIN below, also set on
   .hero-art and .content-panel) so the transition reads as the view pushing into a spot on the
   ridge and the gallery growing out of it, not two panels swapping places. */
.hero {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  align-items: flex-end;
  overflow: hidden;
  padding: 0;
  transition: opacity 0.35s ease 0.1s;
  /* sky behind the peak canvases below (they don't cover every pixel) — same stops as #skyGrad. */
  background: linear-gradient(to bottom, var(--sky-top), var(--sky-bottom));
}
body.content-active .hero {
  opacity: 0;
  pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
  .hero,
  .content-panel {
    transition: none !important;
  }
}
.hero-art {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  /* VISTA_ORIGIN — must match .content-panel's transform-origin below; the two panels zoom
     to/from this same point so the ridge and the gallery meet at one spot on screen. */
  transform-origin: 50% 38%;
  transition:
    opacity 0.18s ease,
    transform 0.55s cubic-bezier(0.4, 0, 0.2, 1);
}
body.content-active .hero-art {
  /* scales the whole wrapper, not the individual peak layers — those already carry their own
     inline transform from the wheel-driven dolly (applyHeroZoom), which would fight a class-based
     transform on the same elements. Scaling the parent composes with that instead of overriding it. */
  transform: scale(7);
}
/* dip-to-opaque while setRidgeTheme() swaps the geometry underneath, so the mid-rebuild frame
   (old rim gone, new fill not yet drawn) never flashes; see the JS for the class's lifecycle. */
.hero-art.is-swapping {
  opacity: 0.4;
}
@media (prefers-reduced-motion: reduce) {
  .hero-art {
    transition: none;
  }
}
/* DOM order alone gives the far→mist→mid→mist→near→rim layering — no z-index needed. */
.hero-art > .peak-layer,
.hero-art > svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
/* 20% bleed each side — heroZoom shrinks to 0.74 min, revealing edges the bitmap must already have. */
.peak-layer > canvas {
  position: absolute;
  top: 0;
  left: -20%;
  width: 140%;
  height: 100%;
  display: block;
}
/* Themed toggle: moon (dark) / sun (light). Positioned on .hero (not inside hero-art's SVG, whose
   preserveAspectRatio could push it off-screen). --moon-glow matches the ridge highlight tone. */
.moon {
  position: absolute;
  top: 11%;
  left: max(9vw, calc(50vw - 900px));
  z-index: 1;
  width: 90px;
  aspect-ratio: 1;
  border-radius: 50%;
  border: none;
  padding: 0;
  cursor: grab;
  touch-action: none;
  background:
    radial-gradient(circle, var(--moon-glow) 0 38%, transparent 40%),
    radial-gradient(
      circle,
      color-mix(in srgb, var(--moon-glow) 50%, transparent) 0%,
      transparent 70%
    );
  transition: background 0.4s ease;
}
.moon.is-dragging {
  cursor: grabbing;
}
:root[data-theme="light"] .moon {
  background:
    radial-gradient(circle, #ffd98a 0 38%, transparent 40%),
    radial-gradient(circle, rgba(255, 197, 110, 0.55) 0%, transparent 70%);
}
/* animation: var(--breathe-anim), not a descendant selector — these <path>s live once inside <symbol id="mtn">, and <use> references them without cloning into the DOM, so only inherited custom properties can scope per-instance. */
.peak-far {
  fill: var(--peak-far);
  animation: var(--breathe-anim);
  animation-play-state: var(--breathe-play);
}
.peak-mid {
  fill: var(--peak-mid);
  animation: var(--breathe-anim);
  animation-delay: -0.6s;
  animation-play-state: var(--breathe-play);
}
.peak-near {
  fill: var(--peak-near);
  animation: var(--breathe-anim);
  animation-delay: -1.3s;
  animation-play-state: var(--breathe-play);
}
/* scoped here — #mtn's own paths (loading icon) stay fully opaque elsewhere. */
.peak-canvas.peak-far {
  opacity: 0.85;
}
.peak-canvas.peak-mid {
  opacity: 0.92;
}
.peak-canvas.peak-near {
  opacity: 0.98;
}
.peak-rim {
  /* no stroke color here — each JS-built segment inside #peakRim sets its own gradient fill (see
     updateRidgeLighting); fill/width/caps still cascade down to them from this shared rule. */
  fill: none;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* daylight has no moon, so the whole segmented rim (not just its color) is hidden outright. */
:root[data-theme="light"] #peakRim {
  display: none;
}
/* the traveling glint now means "loading" (see .loading-mtn) — --ridge-anim defaults to none and is only switched on within .loading-mtn's scope via inheritance (same mechanism as --breathe-anim). */
.peak-rim-sm {
  stroke: var(--rim-stroke-sm);
  stroke-width: 0.8;
  stroke-dasharray: 25 500;
  animation: var(--ridge-anim);
}
@keyframes ridgeFlow {
  to {
    stroke-dashoffset: -525;
  }
}
/* dolly transform (heroZoom below) goes on .peak-layer so its canvas always paints full at 1:1. */
.hero-art .peak-layer {
  transform-origin: 50% 100%;
  transition: transform 0.2s ease-out;
}
.hero-art .peak-rim {
  transform-box: view-box;
  transform-origin: 50% 100%;
  transition: transform 0.2s ease-out;
}
.mist {
  fill: var(--mist);
  opacity: 0.5;
}
@media (prefers-reduced-motion: no-preference) {
  .mist-1 {
    animation: drift1 58s ease-in-out infinite;
  }
  .mist-2 {
    animation: drift2 74s ease-in-out infinite;
  }
  .mist-3 {
    animation: drift3 46s ease-in-out infinite;
  }
}
@keyframes drift1 {
  0%,
  100% {
    transform: translateX(0);
  }
  50% {
    transform: translateX(60px);
  }
}
@keyframes drift2 {
  0%,
  100% {
    transform: translateX(0);
  }
  50% {
    transform: translateX(-80px);
  }
}
@keyframes drift3 {
  0%,
  100% {
    transform: translateX(0);
  }
  50% {
    transform: translateX(40px);
  }
}

.hero-content {
  position: relative;
  z-index: 2;
  width: 100%;
  max-width: 1200px;
  margin: 0 auto;
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 2rem;
  padding: 0 clamp(1.25rem, 5vw, 3rem) clamp(3rem, 8vw, 5rem);
  opacity: 0;
  transform: translateY(18px);
  animation: heroIn 1s ease 0.15s forwards;
  /* z-index:2 (above .moon) — the box spans beyond its visible children, so pointer-events: none
     keeps it from eating hover/drag over the mountains; only .cta/.wordmark-btn get them back. */
  pointer-events: none;
}
.hero-content .cta,
.hero-content .wordmark-btn {
  pointer-events: auto;
}
@media (prefers-reduced-motion: reduce) {
  .hero-content {
    opacity: 1;
    transform: none;
    animation: none;
  }
}
@keyframes heroIn {
  to {
    opacity: 1;
    transform: none;
  }
}

/* Shifts left so the moon clears this block's right edge instead of hovering over the tagline.
   Below 1200px: the margin collapses to safe-inset with a smooth clamp; above: -20rem dominates. */
.hero-left {
  max-width: 22rem;
  margin-left: max(
    -20rem,
    calc(
      clamp(0.75rem, 3vw, 2.25rem) -
        (clamp(1.25rem, 5vw, 3rem) + max(0px, 50vw - 600px))
    )
  );
}
.tagline {
  font-family: var(--font-body);
  font-size: clamp(1.1rem, 2vw, 1.4rem);
  color: var(--hero-text);
  margin: 0 0 2rem;
  letter-spacing: 0.05em;
}
.hero-actions {
  display: flex;
  align-items: center;
  gap: 1.25rem;
}
.cta {
  display: inline-block;
  border: 1px solid var(--hero-text);
  color: var(--hero-text);
  background: none;
  cursor: pointer;
  padding: 0.75em 1.75em;
  font-family: var(--font-ui);
  font-size: 0.85rem;
  letter-spacing: 0.15em;
  text-decoration: none;
  position: relative;
  transition:
    color 0.3s,
    border-color 0.3s;
}
.cta::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: -1px;
  width: 0;
  height: 2px;
  background: var(--zhusha);
  transition: width 0.3s ease;
}
.cta:hover,
.cta:focus-visible {
  border-color: var(--zhusha);
  color: var(--zhusha);
}
.cta:hover::after,
.cta:focus-visible::after {
  width: 100%;
}

.seal {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 52px;
  height: 52px;
  flex: 0 0 auto;
  background: var(--zhusha);
  color: var(--seal-glyph);
  font-family: var(--font-display);
  font-size: 20px;
  letter-spacing: 0.1em;
  writing-mode: vertical-rl;
  transform: rotate(-3deg);
  box-shadow: 0 2px 8px var(--shadow);
  clip-path: polygon(
    6% 2%,
    94% 0%,
    100% 20%,
    97% 45%,
    100% 70%,
    95% 98%,
    70% 100%,
    40% 97%,
    8% 100%,
    0% 75%,
    4% 50%,
    0% 22%
  );
}

/* Mirrors .hero-left's shift, pushing right instead of left. */
.wordmark {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(4rem, 11vw, 8.5rem);
  letter-spacing: 0.18em;
  line-height: 1;
  writing-mode: vertical-rl;
  color: var(--hero-text);
  margin: 0
    max(
      -18rem,
      calc(
        clamp(0.75rem, 3vw, 2.25rem) -
          (clamp(1.25rem, 5vw, 3rem) + max(0px, 50vw - 600px))
      )
    )
    0 0;
}
@media (max-width: 640px) {
  .wordmark {
    writing-mode: horizontal-tb;
    font-size: clamp(3rem, 16vw, 4.5rem);
    margin-right: 0;
  }
  .hero-content {
    flex-direction: column-reverse;
    align-items: flex-start;
  }
  .hero-left {
    margin-left: 0;
  }
}
/* inherits the h1's own font/size/color/writing-mode rather than restating them, so the button
   always matches .wordmark's current (including the 640px breakpoint's horizontal) layout. */
.wordmark-btn {
  font: inherit;
  color: inherit;
  letter-spacing: inherit;
  writing-mode: inherit;
  background: none;
  border: none;
  padding: 0;
  margin: 0;
  cursor: pointer;
}
/* set via the disabled attribute (not a class) when only one ridge theme exists -- native
   semantics: removed from the tab order and un-clickable, matching "nothing to switch to". */
.wordmark-btn:disabled {
  cursor: default;
}

/* ---------- Shared mountain art — the loading placeholder wherever a wallpaper <img> is being fetched ---------- */
.mtn-art {
  width: 100%;
  height: 100%;
  display: block;
}

/* shared loading placeholder base — the main viewer's instance carries a full-bleed mountain-art icon
   (dark mode: ridge-tip moonlight glint; light mode, no --rim-stroke-sm: the three layers breathing
   instead) since it's one photo loading at a time; filmstrip thumbnails skip the icon entirely (a whole
   row of detailed mountain art loading/breathing at once reads as clutter at their small size) and fall
   back to just this plain static filled box, no animation of its own. Untinted — a wallpaper can carry
   several tags at once now, so there's no single "which tag" to color it by (the old per-mood hue-rotate
   filter only worked for a fixed, known set of categories). */
.loading-mtn {
  position: absolute;
  inset: 0;
  overflow: hidden;
  /* z-index: 2, above the sibling <img>'s z-index: 1 — without it, DOM order broke the tie during opacity<1 stacking contexts and let the stale <img> paint on top of this "opaque" loader. */
  z-index: 2;
  opacity: 1;
  /* purely decorative — never intercept clicks meant for the sibling <img> underneath, even at opacity 1; without this an is-hidden (opacity:0) loader still ate every click. */
  pointer-events: none;
  /* no transition here on purpose — must snap opaque instantly to cover the stale bitmap, since a fade-in here races the <img>'s decode time (cached photos decoded faster than the old .3s fade, letting stale content show through). */
  display: flex;
  align-items: center;
  justify-content: center;
  /* filmstrip thumbnails have no mtn-art child at all (see makeLoadingMtn) — this stays the visible
     loader there; the viewer's mtn-art below isn't fully opaque either (its own SVG silhouette doesn't
     paint every pixel of the box), so both need this same fill underneath. */
  background: var(--xuan-deep);
}
/* full-bleed — the viewer's loader is a single instance (one photo loading at a time), not a grid of
   many, so the detailed mountain artwork reads fine here even though it's dropped from thumbnails. */
.viewer-loading-art .mtn-art {
  width: 100%;
  height: 100%;
}
/* only the viewer's own loader gets a percentage — filmstrip thumbnails are small/fast enough
   that tracking their progress isn't worth the fetch-as-blob cost (see loadViewerImg's JS).
   Positioned over the mtn-art rather than sharing its flex row, so an empty string (no progress
   yet, or total size unknown) collapses to nothing instead of leaving a gap. */
.viewer-loading-pct {
  position: absolute;
  left: 50%;
  bottom: 14%;
  transform: translateX(-50%);
  font-family: var(--font-ui);
  font-size: 0.8rem;
  letter-spacing: 0.08em;
  color: var(--dai);
  background: color-mix(in srgb, var(--xuan-deep) 65%, transparent);
  padding: 0.2em 0.7em;
  border-radius: 2px;
}
.viewer-loading-pct:empty {
  display: none;
}
.loading-mtn.is-hidden {
  opacity: 0;
  transition: opacity 0.3s ease;
}
@media (prefers-reduced-motion: no-preference) {
  /* scoped to .viewer-loading-art itself (not the shared .loading-mtn base) — these two vars only ever
     reach the mtn-art SVG's inner paths via inheritance, and filmstrip thumbnails have no such SVG to
     inherit into (see makeLoadingMtn); a plain static color block needs neither. */
  .viewer-loading-art {
    --breathe-anim: breathe 3.4s ease-in-out infinite;
    --ridge-anim: ridgeFlow 4.2s linear infinite;
  }
}
/* brightness, not opacity — avoids peaks going see-through mid-cycle. */
@keyframes breathe {
  0%,
  100% {
    filter: brightness(1);
  }
  50% {
    filter: brightness(1.35);
  }
}

/* z-index: 1 pins every real-photo <img> below .loading-mtn's z-index: 2 — both sit at opacity<1 during a load (own stacking context), so without this, DOM order would let the stale bitmap show through the "opaque" loader. */
.viewer-img,
.filmstrip-item img {
  width: 100%;
  height: 100%;
  display: block;
  opacity: 0;
  transition: opacity 0.3s ease;
  position: relative;
  z-index: 1;
}
.viewer-img.is-loaded,
.filmstrip-item img.is-loaded {
  opacity: 1;
}
.viewer-img {
  object-fit: contain;
}
.filmstrip-item img {
  object-fit: cover;
  background: var(--xuan-deep);
}

/* ---------- Content panel (tag filter + viewer + filmstrip + footer) ---------- */
/* the other half of the hero/content-panel pair above — starts translated fully below the viewport,
   slides up to inset:0 when body.content-active. overflow: hidden, not auto — every child here is
   sized to always fit within 100vh (the viewer shows one photo at a time regardless of how many
   wallpapers exist; the tag row and filmstrip are the two places explicitly allowed to scroll, and
   only horizontally), so this panel itself never needs to scroll. */
.content-panel {
  position: absolute;
  inset: 0;
  z-index: 1;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(0.6rem, 2vh, 1.25rem);
  /* bottom: 0 — .footer's own padding (see .footer below) already provides its edge buffer; adding
     more here on top of that stacked asymmetrically below the text with nothing matching above it.
     top: 1.25rem matches .content-back's own top offset — .viewer-meta's row (tag filter + photo
     info) shares that same row with the fixed "back" button instead of owning a separate one below
     it, reclaiming the row .content-back would otherwise sit alone on. syncViewerMetaLayout()
     below reserves the button's actual width out of .viewer-meta's left edge so its content never
     renders underneath it; on viewports too narrow for that reservation to leave any room, it falls
     back to pushing the row below the button instead (see that function). */
  padding: 1.25rem clamp(1.25rem, 5vw, 4rem) 0;
  background: var(--xuan);
  /* VISTA_ORIGIN — must match .hero-art's transform-origin above. */
  transform-origin: 50% 38%;
  transform: scale(0.04);
  opacity: 0;
  transition:
    transform 0.55s cubic-bezier(0.4, 0, 0.2, 1),
    opacity 0.3s ease 0.12s;
}
body.content-active .content-panel {
  transform: scale(1);
  opacity: 1;
}

/* shared "horizontal chip/thumbnail strip" shape used by both .tag-filter and .filmstrip — just the
   scroll/scrollbar treatment; each consumer's own box sizing (full-width-and-centered for .filmstrip,
   auto-width-and-left-aligned for .tag-filter, sharing the row with .viewer-info) is genuinely
   different per usage, so it lives on .filmstrip/.tag-filter directly rather than being fought here. */
.h-scroll-row {
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: thin;
  scrollbar-color: var(--cloud) transparent;
}
/* set by enableHScroll's pointer handlers once an actual drag (not just a click) is detected */
.h-scroll-row.is-dragging {
  cursor: grabbing;
  user-select: none;
}
.h-scroll-row::-webkit-scrollbar {
  height: 6px;
}
.h-scroll-row::-webkit-scrollbar-thumb {
  background: var(--cloud);
  border-radius: 4px;
}
.h-scroll-track {
  display: flex;
  flex-wrap: nowrap;
  gap: 0.5rem;
  width: max-content;
  cursor: grab;
}

/* .tag-filter is a <section>, which needs padding: 0 to override the generic
   `section { padding: clamp(3rem, 8vw, 6rem) ... }` rule above — a <section> that skips this override
   is exactly what silently overflowed a short viewport earlier this session (up to 6rem top + 6rem
   bottom of padding, invisible until you actually shrink the window). flex:1 1 auto (not 0 0 auto)
   because it shares .viewer-meta's row with .viewer-info instead of owning a full row to itself —
   min-width:0 lets it shrink all the way down and scroll internally as the last resort, once
   syncViewerMetaLayout() (see .viewer-meta above) has already handed this row every pixel the
   viewport and the content-back button reservation weren't using.
   No margin:auto on the track (unlike .filmstrip-track below) — this one starts flush at the row's
   left edge rather than centering in whatever space it's given. */
.tag-filter {
  padding: 0;
  flex: 1 1 auto;
  width: auto;
  min-width: 0;
}
.tag-filter-track {
  padding: 0 0.25rem 0.4rem;
}
/* small square, not a pill — border-radius: 2px matches the site's other tag/label chips
   (.viewer-tags etc.) rather than reading as a rounded toggle pill. */
.tag-chip {
  flex: 0 0 auto;
  /* font-size + padding + line-height match .viewer-download/.content-back —
     same reasoning as .content-back's own comment: this is a <button>, which needs line-height
     re-asserted explicitly or its UA-stylesheet default (line-height: normal) overrides the body's
     inherited value and leaves it a few px shorter than everything else sharing this row. */
  font-family: var(--font-ui);
  font-size: 0.72rem;
  line-height: inherit;
  letter-spacing: 0.05em;
  color: var(--dai);
  background: none;
  cursor: pointer;
  border: 1px solid var(--cloud);
  border-radius: 2px;
  padding: 0.35em 1.1em;
  white-space: nowrap;
  transition:
    border-color 0.2s ease,
    color 0.2s ease,
    background-color 0.2s ease;
}
.tag-chip:hover,
.tag-chip:focus-visible {
  border-color: var(--zhusha);
  color: var(--zhusha);
}
.tag-chip.is-active {
  border-color: var(--zhusha);
  color: var(--zhusha);
  background: color-mix(in srgb, var(--zhusha) 14%, transparent);
}

/* the viewer is the space-absorber (flex: 1 1 0, not 1 1 auto — see .tag-filter's comment above for
   why every flex-basis in this panel is pinned rather than left to auto/content-driven sizing).
   No overflow:hidden here (unlike .viewer-stage below) — .viewer-meta's syncViewerMetaLayout() can
   deliberately bleed past this box's own edges (its last-resort stage, negative margins) to reach
   all the way out to .content-panel's padding; clipping at .viewer's own boundary would silently
   cut that bleed off before it got there (confirmed via elementFromPoint: the bled tag-filter
   content painted nothing and hit-tested straight through to .content-panel's background). */
.viewer {
  /* matches .filmstrip's own max-width below — both are centered independently in .content-panel's
     column flex, so a mismatched max-width here left their right edges misaligned by the difference. */
  flex: 1 1 0;
  min-height: 0;
  width: 100%;
  max-width: 1200px;
  padding: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
}
.viewer-stage {
  /* overflow:hidden lives here, not on .viewer — this is the piece whose content (the aspect-ratio-
     sized, letterboxed .viewer-frame) actually needs vertical containment; .viewer-meta never did. */
  position: relative;
  overflow: hidden;
  flex: 1 1 0;
  min-height: 0;
  width: 100%;
}
/* reserves room for .viewer-frame's own box-shadow (below) to bleed into before hitting .viewer-
   stage's overflow:hidden clip — a frame letterboxed flush against that clip otherwise cuts the
   shadow down to nearly nothing. Padding on .viewer-stage can't do this instead: an absolutely
   positioned child's containing block is its ancestor's *padding* box, so .viewer-frame's inset:0
   would still reach the padding's outer edge and get clipped just the same — this wrapper exists
   purely to give .viewer-frame a smaller, already-shrunk box to inset:0 into.
   Asymmetric on purpose, not a flat value on every side: the shadow itself is bottom-heavy (see
   .viewer-frame's box-shadow) with only a sliver of reach above the frame — reserving equal room on
   every side would exhume that sliver into a visible ring above the photo, reading as lit from
   below. left/right cover the side layers' full blur-radius reach; bottom is deliberately less than
   the outer layer's full offset+blur-radius reach (68px) — that outer layer is already down to 20%
   alpha (see .viewer-frame's box-shadow), fading below perceptible well short of its nominal reach,
   and reserving the full amount stacked on top of .content-panel's own flex gap read as a dead gap
   before .filmstrip. top stays 0 so no shadow shows above at all, matching the light-from-above look
   the layers already implied before they were clipped. */
.viewer-frame-slot {
  position: absolute;
  inset: 0 48px 36px;
}
.content-panel.is-zoomed .viewer-frame-slot {
  inset: 0;
}
/* clicking the display area fills the screen: content-panel drops its own padding, and only .viewer
   stays visible among its direct children — an allow-list (hide everything, then un-hide .viewer)
   rather than naming each sibling to hide, so a future direct child of .content-panel is hidden by
   default in zoom mode instead of silently showing through. viewer-frame drops its per-image
   aspect-ratio to grow edge-to-edge, and the image switches from contain to cover so it crops to
   fill rather than letterboxing. */
.content-panel.is-zoomed {
  padding: 0;
}
.content-panel.is-zoomed > * {
  display: none;
}
.content-panel.is-zoomed > .viewer {
  display: flex;
  max-width: none;
}
.content-panel.is-zoomed .viewer-meta {
  display: none;
}
.content-panel.is-zoomed .viewer-frame {
  aspect-ratio: auto;
  width: 100%;
  height: 100%;
  border-radius: 0;
  box-shadow: none;
  cursor: zoom-out;
}
.content-panel.is-zoomed .viewer-img {
  object-fit: cover;
}
/* position:absolute+inset:0+margin:auto, not flex+align-items — flex's max-content auto-sizing collapses
   this to zero before the photo has an intrinsic size, briefly hiding the loading animation; this
   shrink-to-fit sizing doesn't. Sized against .viewer-frame-slot above, not .viewer-stage directly —
   see that rule for why the shadow needs a dedicated, asymmetrically-shrunk box to inset:0 into. */
.viewer-frame {
  position: absolute;
  inset: 0;
  margin: auto;
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: 100%;
  aspect-ratio: var(--photo-ratio, 1.6 / 1);
  transition: aspect-ratio 0.3s ease;
  border-radius: 2px;
  overflow: hidden;
  /* five layers, all bottom-only, blur/offset roughly doubling each step (Josh Comeau's "smooth
     shadow" recipe) instead of two flat-alpha layers — two layers each render a smooth falloff on
     their own, but stacking only two leaves a visible kink where the near layer's contribution hits
     zero and only the far layer's is still going. More samples along the curve, each fainter than
     the last as it gets wider, reads as one continuous fade instead of two overlapping bands. Same
     total 48px side / 68px downward reach as before (see .viewer-frame-slot above), so the reserved
     gutter didn't need to change. Uses --frame-shadow, not --shadow (see that variable's definition)
     — 5 stacked layers compound well past any single layer's own alpha, and reusing --shadow's flat
     dark-theme value here overshot into a harsh near-black band right at the frame's edge rather
     than the intended gradient.
     Plus one inset highlight along the top edge, independent of theme — pairing a highlight with the
     shadow is what actually reads as "lit from above" rather than "faded at the edges"; the shadow
     alone (however well-tuned) only encodes half of that cue. Inset shadows paint on top of the
     frame's own content (verified: it isn't hidden behind .viewer-img) and don't need gutter room
     since they never bleed past the box. */
  box-shadow:
    0 2px 4px color-mix(in srgb, var(--frame-shadow) 60%, transparent),
    0 4px 8px color-mix(in srgb, var(--frame-shadow) 50%, transparent),
    0 8px 16px color-mix(in srgb, var(--frame-shadow) 40%, transparent),
    0 14px 32px color-mix(in srgb, var(--frame-shadow) 30%, transparent),
    0 20px 48px color-mix(in srgb, var(--frame-shadow) 20%, transparent),
    inset 0 1px 0 rgba(255, 255, 255, 0.14);
  background: var(--xuan-deep);
  cursor: zoom-in;
}
@media (prefers-reduced-motion: reduce) {
  .viewer-frame {
    transition: none;
  }
}
/* a row above the photo, not overlaid on it — tag filter left, current-photo tag/info/download
   right, same row, never wrapping. .tag-filter's own flex:1 1 auto (see its rule above) absorbs the
   row's free space, so .viewer-info (flex:0 0 auto below) is pushed flush to the right with no
   justify-content needed. Stays single-row even when tight: syncViewerMetaLayout() (JS) bleeds this
   row past .content-panel's own side padding, right out to the viewport edge, rather than letting
   flex wrap it onto a second line. */
.viewer-meta {
  flex: 0 0 auto;
  width: 100%;
  display: flex;
  align-items: center;
  gap: 0.6rem;
  font-family: var(--font-ui);
  font-size: 0.72rem;
  color: var(--dai);
}
/* [hidden] alone does nothing here — an author stylesheet's `display` always wins over the UA
   stylesheet's [hidden]{display:none}, regardless of specificity, so the flex below needs its own
   explicit override or the tag/dims/download row stays visible (with stale content) once empty. */
.viewer-meta[hidden] {
  display: none;
}
.viewer-info {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: 0.6rem;
}
.viewer-tags {
  border: 1px solid var(--cloud);
  padding: 0.15em 0.5em;
  border-radius: 2px;
  letter-spacing: 0.05em;
}
.viewer-download {
  padding: 0.35em 1.1em;
  text-decoration: none;
  color: var(--ink);
  border: 1px solid var(--cloud);
  transition:
    border-color 0.25s ease,
    color 0.25s ease;
}
.viewer-download:hover,
.viewer-download:focus-visible {
  border-color: var(--zhusha);
  color: var(--zhusha);
}
.viewer-empty {
  font-family: var(--font-ui);
  font-size: 0.82rem;
  color: var(--dai);
  text-align: center;
}

/* full-width-and-centered — the one consumer of .h-scroll-row/.h-scroll-track that actually wants
   that shape (see the shared rule's comment above); .tag-filter wants the opposite (shrink + left-
   align), so this lives here instead of on the shared base. */
/* scrollbar suppressed here specifically — .tag-filter keeps the shared thin scrollbar from
   .h-scroll-row, but the filmstrip is wide/full-row enough that a visible bar reads as clutter. */
.filmstrip {
  flex: 0 0 auto;
  width: 100%;
  max-width: 1200px;
  scrollbar-width: none;
  /* cancels .viewer-frame-slot's 36px bottom reserve (see that rule) — without this, .content-panel's
     flex gap stacks on top of that reserve and this gap reads as much bigger than .viewer-meta's gap
     above the photo, even though both are the same flex gap value. The reserve's own space still
     exists above this pulled-up position; the shadow's tail is already too faint there to read as
     touching the filmstrip. */
  margin-top: -36px;
}
.filmstrip::-webkit-scrollbar {
  display: none;
}
.filmstrip-track {
  padding: 0.25rem;
  margin: 0 auto;
}
.filmstrip-item {
  position: relative;
  flex: 0 0 auto;
  width: clamp(76px, 11vh, 128px);
  aspect-ratio: 4 / 3;
  border-radius: 2px;
  overflow: hidden;
  border: 2px solid transparent;
  padding: 0;
  cursor: pointer;
  background: var(--xuan-deep);
  transition:
    border-color 0.2s ease,
    transform 0.2s ease;
}
.filmstrip-item:hover,
.filmstrip-item:focus-visible {
  transform: translateY(-2px);
}
.filmstrip-item.is-active {
  border-color: var(--zhusha);
}
/* the progressive-mount load-more marker (see filmstripObserver in the JS) — an explicit tiny box
   rather than relying on an unstyled div's default (often 0×0) layout box, which IntersectionObserver
   can trigger on unreliably across browsers, especially combined with a horizontal rootMargin. */
.filmstrip-sentinel {
  flex: 0 0 1px;
  width: 1px;
}

.content-back {
  position: fixed;
  top: 1.25rem;
  left: 1.25rem;
  z-index: 3;
  /* font-size + padding + line-height match .viewer-download below (same font-family, border,
     box model) specifically so this button's height comes out identical to it — it now sits on
     the same visual row as it does, sharing .content-back's row with
     .viewer-info. line-height needs to be re-asserted: <button> carries its own UA-stylesheet
     line-height: normal that wins over the body's inherited value (line ~78) — <a>.viewer-download
     has no such default and just inherits it directly, so without this line the two still end up
     a couple pixels apart despite every other property matching. */
  font-family: var(--font-ui);
  font-size: 0.72rem;
  line-height: inherit;
  letter-spacing: 0.1em;
  color: var(--ink);
  background: var(--xuan);
  border: 1px solid var(--cloud);
  padding: 0.35em 1.1em;
  cursor: pointer;
  opacity: 0;
  transform: translateY(-8px);
  pointer-events: none;
  transition:
    opacity 0.4s ease 0.15s,
    transform 0.4s ease 0.15s,
    border-color 0.25s ease,
    color 0.25s ease;
}
body.content-active .content-back {
  opacity: 1;
  transform: none;
  pointer-events: auto;
}
.content-back:hover,
.content-back:focus-visible {
  border-color: var(--zhusha);
  color: var(--zhusha);
}

.reveal {
  opacity: 0;
  transform: translateY(24px);
  transition:
    opacity 0.6s ease,
    transform 0.6s ease;
}
.reveal.in {
  opacity: 1;
  transform: none;
}

/* ---------- Footer ---------- */
.footer {
  /* width: 100%, not shrink-to-fit — content-panel's align-items: center leaves flex children to size
     themselves, and a shrink-to-fit width on a flex-wrap element can settle narrower than necessary,
     wrapping onto extra lines it didn't need to and throwing off the panel's total-height budget. */
  flex: 0 0 auto;
  width: 100%;
  text-align: center;
  padding: 0.75rem 1.25rem;
  border-top: 1px solid var(--cloud);
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 0.6rem 0.9rem;
}
.footer .seal {
  width: 30px;
  height: 30px;
  font-size: 12px;
}
.footer p {
  font-family: var(--font-ui);
  font-size: 0.82rem;
  color: var(--dai);
  margin: 0;
}
.footer .fine {
  font-size: 0.72rem;
  letter-spacing: 0.1em;
}

:focus-visible {
  outline: 2px solid var(--zhusha);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .mist {
    animation: none !important;
  }
  .reveal {
    opacity: 1 !important;
    transform: none !important;
    transition: none !important;
  }
}
