/* ════════════════════════════════════════════════════════════════════
   ROOMS — list + detail (dark base, paintings pop)
   The backdrop image (single or mosaic) is hugely blurred and slightly
   darkened. The flow canvas paints radial waves emanating from each
   painting on top of that. The painting itself sits crisp, slightly
   brightened, with a halo shadow so it reads as the light source.
   ──────────────────────────────────────────────────────────────────── */

/* ── Ambient backdrop (room_detail) ──────────────────────────────── */
.room-bg {
  position: fixed;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
  background: var(--bg-deeper);
}
.room-bg-img {
  position: absolute;
  left: -10%; top: -10%;
  width: 120%; height: 120%;
  object-fit: cover;
  /* The default treatment, still used by any page that does not opt into
     the drift below. room_detail does opt in, and overrides both of these. */
  filter: blur(28px) saturate(1.25) brightness(0.72);
  transform: scale(1.1);
  opacity: 1;
}
/* ---- The backdrop drifts, and does not blur ----------------------
   Zoomed hard and held dark instead: an upscaled painting is already soft,
   and it keeps the paint reading as paint rather than as a colour wash.

   THIS ONE IS PERPETUAL, unlike the ceremony, which is exactly the shape
   the house rule was written against - so what makes it affordable is worth
   naming. TWO layers, not ten. TRANSFORM only: the filters here are static,
   so they rasterise once into the layer and every frame after is compositor
   work with no repaint. NO blur anywhere near an animation - that pairing is
   what killed the old backdrop. Ninety-second cycles. Paused when the tab is
   hidden, and off entirely under prefers-reduced-motion.

   It also REMOVES more work than it adds: bg.js gates its FFT + chained
   feDisplacementMap loop on a `.room-bg-img--reactive` element existing, and
   nothing carries that class any more. The most expensive thing on this page
   switched itself off.

   If it ever needs to be cheaper still, delete --ghost first: the second
   layer is what doubles the compositing, and the base alone still drifts. */
.room-bg-img--drift {
  filter: saturate(1.18) brightness(0.34);
  transform: scale(2.1) translateY(-7%);
  animation: rb-drift 96s ease-in-out infinite alternate;
  will-change: transform;
}
/* img.<class>, not .<class>: base.css sets img.loaded{opacity:1}, which is
   the more specific selector and would otherwise win this outright. */
img.room-bg-img--ghost {
  opacity: 0.32;
  filter: saturate(1.1) brightness(0.52);
  transform: scale(3.05) translateY(-11%);
  animation: rb-drift-ghost 61s ease-in-out infinite alternate;
}

/* `alternate`, not a loop: one image cannot fall forever without a seam,
   and a seam is the one thing a background must not have. At ninety seconds
   a leg the turn is imperceptible - it reads as breathing. The two layers
   run at different lengths so they never swing together. */
@keyframes rb-drift {
  from { transform: scale(2.1) translateY(-7%); }
  to   { transform: scale(2.1) translateY(7%); }
}
@keyframes rb-drift-ghost {
  from { transform: scale(3.05) translateY(-11%); }
  to   { transform: scale(3.05) translateY(11%); }
}

body.tab-hidden .room-bg-img--drift { animation-play-state: paused; }

@media (prefers-reduced-motion: reduce) {
  .room-bg-img--drift { animation: none; }
}

/* ── Ambient backdrop (room_list) — every cover stacked + blended ─
   Earlier this was a grid of columns; with the gentler blur the
   column boundaries leaked through as vertical stripes. Tiles are
   now full-viewport absolutely-positioned and overlap each other,
   each at a low opacity so all the paintings dissolve into one
   continuous wash of colour.
   ────────────────────────────────────────────────────────────── */
.rooms-bg {
  position: fixed;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
  background: var(--bg-deeper);
}
/* The image is now ONE pre-blurred + pre-darkened composite WebP
   baked by the `composite_backdrop` management command. No runtime
   blur is needed, which is the whole point — that's what was killing
   phones. A tiny additional saturate boost is the only CSS work. */
.rooms-bg-tile {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* The baked composite is small; stretched to a hi-DPI screen it shows
     upscaling blockiness. A light blur on this ONE static layer smooths it
     out — cheap (unlike the per-cover runtime blur we removed). For a sharper
     base, re-bake with a bigger TILE (see composite_backdrop.py). */
  filter: saturate(1.05) blur(9px);
  transform: scale(1.1);     /* hide the soft edge the blur introduces */
  opacity: 1;
}

/* Content sits above the canvas. */
.rooms-grid, .room-doc { position: relative; z-index: 2; }

/* ── ROOM LIST grid ──────────────────────────────────────────────── */
.rooms-grid {
  width: 90%;
  max-width: 1100px;
  margin: calc(env(safe-area-inset-top, 0px) + 4rem) auto 6rem;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: 2rem;
}

.room-card {
  position: relative;
  aspect-ratio: 1/1;
  overflow: hidden;
  cursor: pointer;
  border-radius: 2px;
  box-shadow:
    0 6px 18px rgba(0, 0, 0, 0.45),
    0 24px 80px rgba(0, 0, 0, 0.50);
}

.room-card img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  transition: transform 1.2s ease, filter 1.2s ease;
  filter: brightness(1.00) saturate(1.08);
}
.room-card:hover img {
  transform: scale(1.03);
  filter: brightness(1.12) saturate(1.15);
}

.room-card-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  padding: 1.5rem;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.60) 0%, transparent 60%);
  opacity: 1;
  transition: opacity 0.6s ease;
  text-decoration: none;
}
.room-card:hover .room-card-overlay { opacity: 0; }

.room-card-title {
  font-size: 0.9rem;
  font-weight: 300;
  letter-spacing: 0.12em;
  color: #fff;
}
.room-card-genre {
  font-size: 0.7rem;
  letter-spacing: 0.1em;
  color: rgba(255, 255, 255, 0.6);
  margin-top: 0.3rem;
}

.room-card-link {
  position: absolute;
  inset: 0;
  z-index: 2;
}

.unreleased { filter: brightness(0.40) !important; }

.unreleased-tag {
  position: absolute;
  top: 1rem;
  right: 1rem;
  font-size: 0.65rem;
  letter-spacing: 0.15em;
  color: rgba(255, 255, 255, 0.6);
  z-index: 3;
}

/* Room detail before release: the painting stands alone as the teaser and the
   tracklist is withheld, so this replaces the player rather than sitting
   beside it. `position: static` undoes the corner-pinning the same tag uses on
   a room card. */
.room-locked {
  position: absolute;
  top: calc(env(safe-area-inset-top, 0px) + 1.5rem);
  left: 0;
  right: 0;
  z-index: 4;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.35rem;
  pointer-events: none;
}
.room-locked .unreleased-tag {
  position: static;
  text-transform: uppercase;
}
.room-locked-date {
  font-size: 0.6rem;
  letter-spacing: 0.18em;
  color: rgba(255, 255, 255, 0.38);
}

/* ── THE GATE — rite of entry to an unreleased room ──────────────── */
/* IN THE FLOW, not absolute. The absolute positioning was correct when the
   room was `position:fixed; inset:0` - one screen, nothing below. Now the
   room is a document, and an element pinned to `bottom:` inside it lands on
   top of the painting and the wall label at desktop heights, where the
   painting is 62vh instead of the phone's 42vh. That is why the rite read
   as working on a phone and broken on a desktop. */
/* The rite now lives INSIDE the veil (see the template): phase A of the
   ceremony is the room going dark with the terminal at its centre. So the
   gate has no box of its own any more - .rv-gate positions it. */

.gate-term {
  width: 100%;
  background: rgba(8, 8, 12, 0.72);
  border: 1px solid rgba(255, 255, 255, 0.10);
  border-radius: 10px;
  padding: 0.75rem 0.85rem 0.85rem;
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
}
.gate-term-bar { display: flex; gap: 0.3rem; margin-bottom: 0.55rem; }
.gate-term-bar span {
  width: 7px; height: 7px; border-radius: 50%;
  background: rgba(255, 255, 255, 0.14);
}

.gate-out {
  margin: 0;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.66rem;
  line-height: 1.55;
  color: rgba(255, 255, 255, 0.62);
  white-space: pre-wrap;
  word-break: break-word;
  max-height: 8.5rem;
  overflow-y: auto;
}

.gate-phrase {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0.6rem;
  padding: 0.5rem 0.6rem;
  background: rgba(255, 255, 255, 0.04);
  border-radius: 6px;
}
.gate-phrase code {
  flex: 1;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.62rem;
  line-height: 1.4;
  color: rgba(255, 255, 255, 0.92);
  word-break: break-all;
  user-select: all;   /* one tap selects the whole key on a phone */
}
.gate-copy, .gate-enter {
  flex-shrink: 0;
  background: none;
  border: 1px solid rgba(255, 255, 255, 0.20);
  border-radius: 5px;
  color: rgba(255, 255, 255, 0.7);
  font-family: var(--font);
  font-size: 0.56rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  padding: 0.35rem 0.6rem;
  cursor: pointer;
  transition: color 0.15s, border-color 0.15s;
}
.gate-copy:hover, .gate-enter:hover { color: #fff; border-color: rgba(255, 255, 255, 0.5); }
.gate-enter:disabled { opacity: 0.4; cursor: default; }

.gate-entry { display: flex; gap: 0.5rem; margin-top: 0.55rem; }
.gate-entry input {
  flex: 1;
  min-width: 0;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 5px;
  color: rgba(255, 255, 255, 0.95);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  /* 16px EXACTLY, and the comment that used to sit here had it backwards:
     iOS zooms when a focused input is SMALLER than 16px, not larger. At
     0.68rem every tap on this field threw the phone into a max zoom nobody
     asked for. 1rem is the floor that stops it. */
  font-size: 1rem;
  padding: 0.7rem 0.7rem;
  min-height: 2.75rem;
}
.gate-entry input:focus { outline: none; border-color: rgba(255, 255, 255, 0.4); }
.gate-entry input:disabled { opacity: 0.4; }

@media (max-width: 480px) {
  .gate-out { max-height: 7rem; }
}

/* ---- ROOM DETAIL - a document, not a stage --------------------
   The painting holds the first screen; the tracklist and the other
   rooms live below it. .room-bg stays fixed, so scrolling moves the
   content across a still painting rather than dragging the whole
   room with it. ------------------------------------------------- */
.room-doc {
  /* Clear of the FAB pair, which is fixed over the bottom of every page. */
  padding-bottom: 9rem;
}

.room-stage {
  /* svh, not vh: on a phone vh is the tallest the viewport ever gets, so a
     100vh stage hides its own scroll cue behind the browser chrome. */
  min-height: 100vh;
  min-height: 100svh;
  /* On a standalone PWA the stage runs under the status bar and the home
     indicator. Padding keeps the centring honest inside the safe area
     instead of centring on the physical screen. */
  padding: calc(env(safe-area-inset-top,    0px) + 1rem)
           calc(env(safe-area-inset-right,  0px) + 1rem)
           calc(env(safe-area-inset-bottom, 0px) + 1rem)
           calc(env(safe-area-inset-left,   0px) + 1rem);
  position: relative;          /* the gate is absolute against this */
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 1.5rem;
}

/* ---- The scroll cue -------------------------------------------------
   The room was one screen with nothing under it, so nothing told a visitor
   there is more. Static - it points, it does not bounce. */
.room-scroll {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  gap: 0.3rem;
  text-decoration: none;
  color: var(--ink-faint);
  font-size: 0.62rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  transition: color 0.4s ease;
}
.room-scroll:hover { color: var(--ink); }
.room-scroll svg {
  width: 20px; height: 20px;
  stroke: currentColor; fill: none;
  stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round;
}

/* ---- The sections below the painting --------------------------------
   Solid dark cards: this is the only long-form readable text on the site
   and it sits over a blurred painting. */
.room-section {
  width: min(90%, 46rem);
  margin: 0 auto;
  padding: 1.4rem 1.5rem 1.6rem;
  border-radius: 0.9rem;
  background: rgba(12, 11, 19, 0.88);
  border: 1px solid rgba(236, 232, 244, 0.10);
  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
}
.room-section + .room-section { margin-top: 2rem; }

.room-section-title {
  margin: 0 0 1rem;
  font-size: 0.7rem;
  font-weight: 400;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

/* ---- In-page tracklist ---------------------------------------------- */
.rt-list { list-style: none; margin: 0; padding: 0; }
.rt-row {
  display: flex;
  align-items: center;
  gap: 0.9rem;
  padding: 0.7rem 0.5rem;
  min-height: 3rem;              /* thumb target */
  border-radius: 6px;
  cursor: pointer;
  border-bottom: 1px solid rgba(236, 232, 244, 0.06);
  transition: background 0.15s ease;
}
.rt-row:last-child { border-bottom: none; }
.rt-row:hover { background: rgba(236, 232, 244, 0.06); }
.rt-num {
  font-size: 0.72rem;
  color: var(--ink-ghost);
  min-width: 1.6rem;
  font-variant-numeric: tabular-nums;
}
.rt-name { font-size: 0.9rem; color: var(--ink); letter-spacing: 0.04em; }
.rt-play {
  margin-left: auto;
  display: inline-flex;
  opacity: 0.3;
  transition: opacity 0.2s ease;
}
.rt-row:hover .rt-play { opacity: 1; }
.rt-play svg { width: 14px; height: 14px; fill: var(--ink); }
/* Touch has no hover - keep the affordance visible. */
@media (hover: none) { .rt-play { opacity: 0.6; } }

/* ---- The other rooms ------------------------------------------------ */
.ro-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
  gap: 1rem;
}
.ro-card {
  position: relative;
  display: block;
  text-decoration: none;
  border-radius: 3px;
  overflow: hidden;
  aspect-ratio: 1/1;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.45);
}
.ro-card img {
  width: 100%; height: 100%;
  object-fit: cover;
  display: block;
  filter: brightness(0.92) saturate(1.06);
  transition: filter 0.6s ease, transform 0.6s ease, opacity 0.6s ease;
}
.ro-card:hover img { filter: brightness(1.10) saturate(1.12); transform: scale(1.03); }
.ro-meta {
  position: absolute;
  inset: auto 0 0 0;
  padding: 0.6rem 0.65rem;
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  background: linear-gradient(to top, rgba(0, 0, 0, 0.80), transparent);
}
.ro-title { font-size: 0.72rem; letter-spacing: 0.1em; color: #fff; }
.ro-soon {
  font-size: 0.55rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.70);
}

.painting-wrap {
  position: relative;
  margin: 0;                    /* <figure> ships with one */
  max-width: 55vw;
  display: flex;
  flex-direction: column;       /* painting, then its wall label */
  align-items: center;
  justify-content: center;
  gap: 1.1rem;
  pointer-events: auto;
}

/* The frame exists so the door can sit at the painting's centre rather than
   the figure's - the figure also holds the wall label. */
.painting-frame {
  position: relative;
  display: flex;
  max-width: 100%;
}

.painting {
  max-width: 100%;
  /* svh, NOT vh. On a phone vh tracks the viewport as the URL bar hides
     and returns, so a vh-sized painting RESIZES while you scroll - and it
     does it in one jump, which is what made scrolling to the tracklist
     look broken. svh is the small viewport height: fixed, chrome-visible,
     and it does not move. The vh line stays as the fallback. */
  max-height: 62vh;
  max-height: 62svh;
  object-fit: contain;
  display: block;
  filter: brightness(1.06) saturate(1.08);
  box-shadow:
    0 2px 6px    rgba(0, 0, 0, 0.55),
    0 18px 48px  rgba(0, 0, 0, 0.55),
    0 48px 120px rgba(0, 0, 0, 0.55),
    0 72px 200px rgba(0, 0, 0, 0.35);
  transition: box-shadow 1.2s ease, transform 1.2s ease, filter 1.2s ease;
}
/* Held in shadow until the door opens. */
.painting--locked {
  filter: brightness(0.32) saturate(0.9);
}
.painting-frame:hover .painting--locked { filter: brightness(0.42) saturate(0.95); }

.room-unlock {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.85rem 1.6rem;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, 0.55);
  background: rgba(12, 11, 19, 0.42);
  color: #fff;
  font-family: var(--font);
  font-size: 0.78rem;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  cursor: pointer;
  transition: transform 0.18s ease, background 0.18s ease, border-color 0.18s ease;
}
.room-unlock:hover {
  transform: translate(-50%, -50%) scale(1.04);
  background: rgba(12, 11, 19, 0.6);
  border-color: #fff;
}
.room-unlock.is-spent { display: none; }
.room-unlock-dot {
  width: 6px; height: 6px; border-radius: 50%;
  background: currentColor;
}

.painting:hover {
  filter: brightness(1.12) saturate(1.12);
  transform: translateY(-2px);
  box-shadow:
    0 2px 6px    rgba(0, 0, 0, 0.55),
    0 24px 60px  rgba(0, 0, 0, 0.58),
    0 60px 140px rgba(0, 0, 0, 0.58),
    0 96px 240px rgba(0, 0, 0, 0.40);
}

.room-exit {
  position: fixed;
  top:  calc(env(safe-area-inset-top,  0px) + 1.5rem);
  left: calc(env(safe-area-inset-left, 0px) + 1.75rem);
  z-index: 200;
  text-decoration: none;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  opacity: 0.65;
  transition: opacity 0.4s ease;
}
.room-exit:hover { opacity: 1; }

.room-exit svg {
  width: 18px;
  height: 18px;
  stroke: var(--ink);
  fill: none;
  stroke-width: 1.5;
}
.room-exit span {
  font-size: 0.7rem;
  letter-spacing: 0.12em;
  color: var(--ink);
}

/* ── WALL LABEL — the room's text ────────────────────────────────
   A gallery hangs a card beside the work; this is that card. It also
   happens to be the only readable text in the room, so it carries the
   page's <h1>. Centred and low-contrast so it reads as part of the hang
   rather than as a caption bolted onto the design. */
.wall-label {
  max-width: min(38rem, 80vw);
  text-align: center;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.5rem;
}
.wall-title {
  margin: 0;
  font-size: 0.95rem;
  font-weight: 400;
  letter-spacing: 0.16em;
  color: var(--ink);
}
.wall-meta {
  margin: 0;
  font-size: 0.68rem;
  letter-spacing: 0.14em;
  color: var(--ink-faint);
}
.wall-dot { margin: 0 0.5rem; color: var(--ink-ghost); }
.wall-link {
  margin-top: 0.3rem;
  font-size: 0.68rem;
  letter-spacing: 0.14em;
  color: var(--ink-faint);
  text-decoration: none;
  border-bottom: 1px solid var(--rule);
  padding-bottom: 2px;
  transition: color 0.4s ease, border-color 0.4s ease;
}
.wall-link:hover { color: var(--ink); border-color: var(--ink-faint); }

@media (max-width: 600px) {
  .painting-wrap { max-width: 90vw; gap: 0.85rem; }
  .painting { max-height: 42vh; max-height: 42svh; }
  .room-exit {
    top:  calc(env(safe-area-inset-top,  0px) + 1.25rem);
    left: calc(env(safe-area-inset-left, 0px) + 1rem);
  }
  .wall-title { font-size: 0.85rem; }
  .room-section { width: 92%; padding: 1.2rem 1.1rem 1.3rem; }
  .ro-grid { grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr)); gap: 0.7rem; }
}

/* ════════════════════════════════════════════════════════════════════
   THE ENTRY — the pre-release door, and the one exception to "nothing
   animates". Granted for this room and this record only: a personal art
   site, a door crossed once. See static/js/ceremony.js for the ordering
   that makes it work (play first, animate second) and the reason the
   button is a play button rather than a curtain.
   ──────────────────────────────────────────────────────────────────── */
.room-entry {
  display: inline-flex;
  align-items: center;
  gap: 0.9rem;
  padding: 0.8rem 1.5rem 0.8rem 1.15rem;
  border-radius: 999px;
  /* Same language as the FAB pair: solid white, black glyph, presence
     from the shadow. The door and the controls are one family. */
  border: 1px solid rgba(19, 18, 26, 0.10);
  background: #fff;
  color: #13121a;
  cursor: pointer;
  font-family: var(--font);
  box-shadow: 0 0.6rem 1.6rem rgba(0, 0, 0, 0.45),
              0 1.5rem 3rem   rgba(0, 0, 0, 0.28);
  transition: transform 0.16s ease, box-shadow 0.16s ease;
}
.room-entry:hover {
  transform: scale(1.03);
  box-shadow: 0 0.8rem 2rem   rgba(0, 0, 0, 0.50),
              0 2rem   3.6rem rgba(0, 0, 0, 0.32);
}
/* Pressed. Instant rather than faded: the veil covers the screen in the
   same frame, so nothing is seen to go and nothing reflows underneath. */
.room-entry.is-spent { display: none; }

.room-entry-icon { display: inline-flex; }
.room-entry-icon svg {
  width: 1.5rem; height: 1.5rem;
  fill: currentColor; stroke: none;
}

.room-entry-text {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.12rem;
  line-height: 1.2;
}
.room-entry-label {
  font-size: 0.85rem;
  letter-spacing: 0.16em;
}
.room-entry-note {
  font-size: 0.58rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: rgba(19, 18, 26, 0.55);
}

/* ---- The transition ------------------------------------------------ */
/* ════════════════════════════════════════════════════════════════════
   THE VEIL — the whole ceremony, in two phases.

   PHASE A (.is-open): the room goes to black, the painting stays but held
   in shadow, the controls fade away and the terminal opens at the centre.
   PHASE B (.is-climbing): the terminal goes, the shadow lifts, and the
   camera climbs the painting for nine seconds while the record plays.

   THREE LAYERS, ONE PAINTING. The base carries the image; two ghosts run
   the same climb faster, further and nearly transparent, so the eye reads
   depth and the whole frame seems to fall past you. Transform and opacity
   ONLY — no blur, no filter on the ghosts, nothing repainting a canvas.
   That distinction is the whole reason this is allowed: the house rule
   exists because two perpetual rAF loops with blur(80px) cooked the phone,
   and this is a one-shot GPU composite that runs once a visit.
   ──────────────────────────────────────────────────────────────────── */
.room-veil {
  position: fixed;
  inset: 0;
  z-index: 1200;               /* over the FAB pair (850) and the sheets */
  background: #000;
  overflow: hidden;            /* the zoom spills; the viewport is the frame */
  opacity: 0;
  transition: opacity 1.15s ease;
}
.room-veil[hidden] { display: none; }
.room-veil.is-open { opacity: 1; }

.rv-frame { position: absolute; inset: 0; }

.rv-layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;           /* fills the frame - this is a camera, not a poster */
  /* base.css fades every <img> in once main.js marks it .loaded; these are
     inside a hidden element at load time, so they state their own. */
  opacity: 1;
  transform-origin: 50% 50%;
  will-change: transform, opacity;
}

/* Phase A: the painting is there, and it is in shadow. */
.rv-base {
  transform: scale(1.06);
  filter: brightness(0.3) saturate(0.92);
  transition: filter 1.2s ease;
}
.rv-ghost-a, .rv-ghost-b { opacity: 0; }

/* The play button on an already-open room skips the rite, so it starts lit. */
.room-veil.is-lit .rv-base { filter: brightness(0.6) saturate(1); }

/* ---- Phase A: the terminal at the centre ------------------------- */
.rv-gate {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  width: min(92vw, 34rem);
  z-index: 3;
  opacity: 0;
  animation: rv-gate-in 0.9s ease 0.5s forwards;
}
.rv-gate.is-done {
  animation: rv-gate-out 0.55s ease forwards;
  pointer-events: none;
}
@keyframes rv-gate-in  { to   { opacity: 1; } }
@keyframes rv-gate-out { from { opacity: 1; } to { opacity: 0; transform: translate(-50%, -54%); } }

/* ---- Phase B: the caption ---------------------------------------- */
.rv-caption {
  position: absolute;
  left: 0;
  right: 0;
  bottom: calc(env(safe-area-inset-bottom, 0px) + 14vh);
  bottom: calc(env(safe-area-inset-bottom, 0px) + 14svh);
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.5rem;
  text-align: center;
  text-shadow: 0 2px 18px rgba(0, 0, 0, 0.8);
  opacity: 0;
}
.rv-title {
  font-size: 1.1rem;
  letter-spacing: 0.22em;
  color: #fff;
}
.rv-note {
  font-size: 0.6rem;
  letter-spacing: 0.26em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.72);
}

/* ---- Phase B: the climb ------------------------------------------ */
.room-veil.is-climbing .rv-base    { animation: rv-base    9s linear forwards; }
.room-veil.is-climbing .rv-ghost-a { animation: rv-ghost-a 9s linear forwards; }
.room-veil.is-climbing .rv-ghost-b { animation: rv-ghost-b 9s linear forwards; }
.room-veil.is-climbing .rv-caption { animation: rv-caption 9s linear forwards; }

/* The camera moves UP the painting, so the painting reads as moving DOWN.
   Zoomed past the frame, translating -14% to +14% is the climb; the shadow
   lifts through the first third and the last fifth pulls back. */
@keyframes rv-base {
  0% {
    transform: scale(1.9) translateY(-14%);
    filter: brightness(0.3) saturate(0.92);
    animation-timing-function: ease-out;
  }
  10% {
    transform: scale(1.9) translateY(-11%);
    filter: brightness(0.72) saturate(1.05);
    animation-timing-function: linear;
  }
  32% {
    transform: scale(1.9) translateY(-4%);
    filter: brightness(1.04) saturate(1.14);
    animation-timing-function: linear;      /* the climb is steady */
  }
  78% {
    transform: scale(1.9) translateY(14%);
    filter: brightness(1.04) saturate(1.14);
    animation-timing-function: cubic-bezier(0.22, 0.61, 0.36, 1);
  }
  /* EXACTLY 1, never below it. object-fit:cover sizes the image to cover the
     viewport at scale 1; anything smaller shrinks it INSIDE that box and the
     black background shows as bars down both sides - a hard vertical edge
     resolving out of the painting, which is what a "squarey fade" is. The
     pull-back is already the whole 1.9 -> 1 over the last fifth; it does not
     need to overshoot, and overshooting is what breaks the frame. */
  100% {
    transform: scale(1) translateY(0);
    filter: brightness(1) saturate(1.08);
  }
}

/* The ghosts travel further in the same nine seconds, which is what makes
   them read as faster, and they leave before the pull-back so only the
   base settles into the room. */
@keyframes rv-ghost-a {
  0%   { transform: scale(2.4) translateY(-30%); opacity: 0; }
  14%  { opacity: 0.16; }
  72%  { opacity: 0.16; }
  84%  { transform: scale(2.4) translateY(30%); opacity: 0; }
  100% { transform: scale(2.4) translateY(34%); opacity: 0; }
}
@keyframes rv-ghost-b {
  0%   { transform: scale(3.1) translateY(-44%); opacity: 0; }
  16%  { opacity: 0.09; }
  70%  { opacity: 0.09; }
  82%  { transform: scale(3.1) translateY(44%); opacity: 0; }
  100% { transform: scale(3.1) translateY(48%); opacity: 0; }
}

@keyframes rv-caption {
  0%, 8%    { opacity: 0; transform: translateY(10px); }
  20%, 74%  { opacity: 1; transform: translateY(0); }
  88%, 100% { opacity: 0; transform: translateY(-8px); }
}

/* ---- The controls step out of the way, and come back ------------- */
.menu-fab, .player-fab {
  transition: opacity 0.85s ease, transform 0.2s, box-shadow 0.18s;
}
body.in-ceremony .menu-fab,
body.in-ceremony .player-fab {
  opacity: 0;
  pointer-events: none;
}

/* The exception was granted against the house rule, not against a stated
   need. Someone who has asked for less motion gets the record and the room
   and no cinema at all — which is the half that matters. */
@media (prefers-reduced-motion: reduce) {
  .room-veil { display: none !important; }
}

/* ---- Phones get fewer and smaller layers -------------------------
   The full treatment paints FIVE full-screen copies of the cover: three in
   the veil, two behind the room. At 3x a 2000px cover is a 6000px texture,
   and five of those is more than a phone will rasterise in time - the
   arrival stops being a pull-back and becomes a wait. So: one ghost instead
   of two, a still backdrop instead of a drifting pair, and gentler scales
   throughout. Three layers, not five, and none of them enormous.

   The look survives because depth came from the PARALLAX between layers,
   not from their number - two moving at different rates still reads as
   depth. What is lost is the third, faintest ghost at 0.09 opacity. */
@media (max-width: 700px), (hover: none) and (pointer: coarse) {
  .rv-ghost-b { display: none; }
  img.room-bg-img--ghost { display: none; }

  .rv-base { transform: scale(1.04); }
  .room-veil.is-climbing .rv-base    { animation-name: rv-base-sm; }
  .room-veil.is-climbing .rv-ghost-a { animation-name: rv-ghost-a-sm; }

  /* The backdrop holds still. One layer that never moves is a texture the
     compositor uploads once; the drift is the part that costs. */
  .room-bg-img--drift {
    transform: scale(1.7) translateY(0);
    animation: none;
    will-change: auto;
  }

  .room-entry { padding: 0.7rem 1.25rem 0.7rem 1rem; gap: 0.7rem; }
  .room-entry-icon svg { width: 1.3rem; height: 1.3rem; }
  .room-unlock { padding: 0.75rem 1.2rem; font-size: 0.7rem; letter-spacing: 0.22em; }
  .rv-title { font-size: 0.95rem; }
}

@keyframes rv-base-sm {
  0% {
    transform: scale(1.55) translateY(-11%);
    filter: brightness(0.3) saturate(0.92);
    animation-timing-function: ease-out;
  }
  10% {
    transform: scale(1.55) translateY(-9%);
    filter: brightness(0.72) saturate(1.05);
    animation-timing-function: linear;
  }
  32% {
    transform: scale(1.55) translateY(-3%);
    filter: brightness(1.04) saturate(1.14);
    animation-timing-function: linear;
  }
  78% {
    transform: scale(1.55) translateY(11%);
    filter: brightness(1.04) saturate(1.14);
    animation-timing-function: cubic-bezier(0.22, 0.61, 0.36, 1);
  }
  100% {
    transform: scale(1) translateY(0);
    filter: brightness(1) saturate(1.08);
  }
}

@keyframes rv-ghost-a-sm {
  0%   { transform: scale(1.95) translateY(-24%); opacity: 0; }
  14%  { opacity: 0.16; }
  72%  { opacity: 0.16; }
  84%  { transform: scale(1.95) translateY(24%); opacity: 0; }
  100% { transform: scale(1.95) translateY(27%); opacity: 0; }
}
