/* =====================================================================
   THE LOST LIBRARY — vertical VN player, Phase 0 spike styles.
   9:16 stage, chat-feed dialogue stacking upward, big speaker portrait.
   ===================================================================== */

:root{
  /* The site XP bar's fixed height, reserved UP FRONT so it never pushes the
     stage on load (the sitewide 37px rule); index.html re-measures the real
     mounted height into this var (the bar wraps taller on narrow screens). */
  --fb-bar-h: 37px;
  --stage-w: 430px;
  --ink: #14110e;
  --cream: #f6efe4;
  --gold: #f2c14e;
  --pov: #bcdcf5;
  --radius: 18px;
  --font: ui-rounded, "Segoe UI", Roboto, system-ui, -apple-system, sans-serif;
  --serif: "Iowan Old Style", "Palatino Linotype", Georgia, serif;
  --text-scale: 1;
}

*{ box-sizing:border-box }
/* Android WebViews "boost" text in blocks they judge to be body copy. That
   inflates the glyphs AFTER layout, so text spills out of pills and cards that
   were sized for the smaller type — which is exactly the clipping seen on
   device 2026-07-25 and could not be reproduced at the same viewport on
   desktop. Opt out; our type scale is already set per-element. */
html{ -webkit-text-size-adjust:100%; text-size-adjust:100% }
html,body{ height:100%; margin:0 }
body{
  background:#000; color:var(--cream); font-family:var(--font);
  overflow:hidden; overscroll-behavior:none;
  -webkit-tap-highlight-color:transparent;
}

/* ── the letterboxed stage ─────────────────────────────────────────── */
#stage{
  position:relative; margin:0 auto; width:100%;
  /* 100vh FIRST as a floor: older iOS WebViews (and a few in-app browsers)
     drop the whole declaration if they don't know `dvh`, which would leave the
     stage at auto height — a collapsed flex column with the absolutely
     positioned furniture floating over nothing.
     minus --fb-bar-h: the fixed site XP bar owns the top of the viewport now;
     without the subtraction it covered the stage's own topbar (key chip,
     settings) and the bottom 37px fell off-screen. */
  margin-top:var(--fb-bar-h);
  height:calc(100vh - var(--fb-bar-h)); height:calc(100dvh - var(--fb-bar-h));
  max-width:var(--stage-w); overflow:hidden;
  background:#0b0a09; user-select:none;
  display:flex; flex-direction:column;
  box-shadow:0 0 60px rgba(0,0,0,.9);
}

/* ── the desktop frame (owner, 2026-07-26) ─────────────────────────────
   Anything wider than the stage used to be flat black. Fill the letterbox
   the way a vertical video fills a flat screen: the CURRENT backdrop,
   heavily blurred and dimmed (vn.js publishes it as --ambient on every
   scene change), a vignette to seat it, and the stage itself framed like
   a card — rounded, hairline border, deep shadow — so it reads as part of
   the site rather than a raw phone viewport. Phones never hit this block. */
@media (min-width: 560px){
  body::before{
    content:""; position:fixed; inset:-48px; z-index:0; pointer-events:none;
    background:#0b0a09 var(--ambient, none) center/cover no-repeat;
    filter:blur(46px) saturate(1.08) brightness(.42);
  }
  body::after{
    content:""; position:fixed; inset:0; z-index:0; pointer-events:none;
    background:radial-gradient(115% 92% at 50% 42%, transparent 38%, rgba(0,0,0,.6) 100%);
  }
  #stage{
    z-index:1;
    height:calc(100vh - 52px - var(--fb-bar-h)); height:calc(100dvh - 52px - var(--fb-bar-h));
    margin:26px auto; margin-top:calc(26px + var(--fb-bar-h));
    border-radius:22px; border:1px solid rgba(246,239,228,.14);
    box-shadow:0 30px 90px rgba(0,0,0,.8);
  }
  /* The ⤢ button fullscreens the DOCUMENT (see vn.js) — the framed card on
     blurred art carries into fullscreen unchanged, which is the better look;
     no per-element :fullscreen override needed or possible. */
}

/* ── background ────────────────────────────────────────────────────── */
.bg{ position:absolute; inset:0; z-index:0; overflow:hidden }
/* STATIC ON PURPOSE (owner call 2026-07-26). This used to run a slow
   ken-burns pan — scale(1) → scale(1.07) over 34s. On desktop it read as the
   plate shifting and jittering rather than as atmosphere: continuously
   animating `transform` on a full-bleed background resamples the whole image
   every frame, and any subpixel rounding shows up as a visible wobble on
   straight edges like a bed frame or a window mullion.

   If movement comes back it should be INTENTIONAL AND AT THE IMAGE LEVEL —
   a separate drifting element (dust motes, smoke, a foreground parallax
   layer) composited over a still plate, not the plate itself panning.
   `inset` is 0 now: the negative inset only existed to give the pan somewhere
   to travel, and was silently cropping ~8% off every background. */
.bg-layer{
  position:absolute; inset:0;
  background-size:cover; background-position:center;
  opacity:0; transition:opacity .45s ease;
  will-change:opacity;
}
.bg-layer.on{ opacity:1 }
/* Readability scrim. It used to open right up at 60% (rgba .12) on the theory
   that "the stream sits on the top half" — but the stream's floor is the top of
   the portrait bay, around 66%, and the beat a reader is ACTUALLY reading is
   always the newest one, i.e. the one sitting on that floor. So the single
   most-read line of the story was landing in the clearest band of the scrim.
   Measured on the jungle plate in e03: the scene marker and the bottom
   narration panel were the two least legible things on screen.
   The dark band now runs the full height of the stream and only opens up
   BELOW it, where the portrait lives and there is no text to protect. */
.bg::after{
  content:""; position:absolute; inset:0; pointer-events:none;
  background:
    linear-gradient(180deg, rgba(6,5,4,.84) 0%, rgba(6,5,4,.62) 42%, rgba(6,5,4,.48) 66%, rgba(6,5,4,.14) 80%, rgba(6,5,4,.58) 100%),
    radial-gradient(120% 70% at 50% 108%, rgba(0,0,0,.55), transparent 60%);
}

/* ── top bar ───────────────────────────────────────────────────────── */
.topbar{
  position:relative; z-index:5; display:flex; align-items:center; gap:6px;
  /* env() is 0 in a browser tab; it earns its keep in standalone/fullscreen,
     where the notch would otherwise sit on the stat chips. */
  padding:calc(10px + env(safe-area-inset-top)) 12px 4px;
}
.topbar .spacer{ flex:1 }
.icon-btn{
  appearance:none; border:0; background:rgba(20,17,14,.5); color:var(--cream);
  width:32px; height:32px; border-radius:50%; font-size:15px; line-height:1;
  cursor:pointer; backdrop-filter:blur(6px); transition:background .15s;
  position:relative;
}
.icon-btn:hover{ background:rgba(20,17,14,.8) }
/* 32px reads right at this size but is a 32px TARGET, in the hardest corner of
   the screen for a thumb. Grow the hit box to ~44px without growing the dot.
   Same trick on the stat chips, the settings switches and the episode picker. */
.icon-btn::after,
.chip::after,
.sw::before,
.eppick .ep::after{ content:""; position:absolute; inset:-6px; border-radius:inherit }
/* Horizontal growth stays under half the 5px gap so neighbouring chips don't
   steal each other's taps; the height is where the win is (26px → 46px). */
.chip::after{ inset:-10px -2px }
.sw::before{ inset:-10px -2px }

/* One focus ring for every control. The UA default resolved to a ~black 1px
   outline against this palette (measured on the settings switches), which is
   invisible — so keyboard users had no focus indicator anywhere in the player. */
.icon-btn:focus-visible,
.chip:focus-visible,
.opt:focus-visible,
.btn:focus-visible,
.sw:focus-visible,
.eppick .ep:focus-visible{
  outline:2px solid var(--gold); outline-offset:3px; border-radius:inherit;
}

/* A full-screen sheet covers the top bar, but the buttons under it stayed in
   the tab order and in the a11y tree — keyboard users tabbed into three
   controls they could not see or press. visibility:hidden removes both.
   (.choices is NOT a sheet: the bar stays live while you are choosing.) */
#stage:has(.sheet) .topbar{ visibility:hidden }

/* stat chips */
.hud{ display:flex; gap:5px; align-items:center; flex-wrap:wrap }
.chip{
  position:relative;
  display:flex; align-items:center; gap:4px; font-size:11px; font-weight:700;
  background:rgba(20,17,14,.58); backdrop-filter:blur(6px);
  border-radius:999px; padding:4px 9px 4px 5px; letter-spacing:.2px;
  border:1px solid rgba(255,255,255,.09);
}
.chip .ic{ font-size:12px }
.chip .val{ min-width:9px; text-align:center; color:var(--gold) }
.chip.bump{ animation:bump .45s ease }
@keyframes bump{ 0%{transform:scale(1)} 35%{transform:scale(1.22)} 100%{transform:scale(1)} }
/* Four stats + the Boss Keys chip is FIVE chips sharing a row with three icon
   buttons. At 430 that fits with 6px to spare; at 390 it overflows by ~4px and
   `flex-wrap` drops the key chip onto a second line, so the top bar grows to
   57px, the keys sit under the stats instead of beside them, and the icon
   buttons no longer line up with anything (measured 390x620, 2026-07-26).
   Tighten the chip below 420px rather than let the bar break. */
@media (max-width: 420px){
  .hud{ gap:4px }
  .chip{ padding:3px 7px 3px 4px; gap:3px }
  .chip .ic{ font-size:11px }
  .chip .val{ min-width:8px }
}

/* ── the stream (the whole point) ──────────────────────────────────── */
.stream{
  position:relative; z-index:4; flex:1 1 auto; min-height:0;
  overflow-y:auto; overscroll-behavior:contain;
  display:flex; flex-direction:column;
  gap:9px; padding:14px 14px 6px;
  scrollbar-width:none;
  /* older bubbles fade toward the top edge, exactly like a timeline */
  -webkit-mask-image:linear-gradient(180deg, transparent 0, #000 42px);
          mask-image:linear-gradient(180deg, transparent 0, #000 42px);
}
/* Beats start at the BOTTOM and grow upward — but do NOT do that with
   `justify-content:flex-end`. Overflow from flex-end spills off the top of a
   scroll container and is UNREACHABLE: scrollHeight stays equal to
   clientHeight while the first beat sits at -2570px. That silently broke the
   whole "scroll up any time to reread" promise (caught 2026-07-25).
   An auto top margin on a zero-height first item does the same visual job and
   collapses to 0 once the content overflows, so scrolling works normally. */
.stream::before{ content:""; margin-top:auto }
.stream > *{ flex:0 0 auto }   /* never let a beat compress to make room */
.stream::-webkit-scrollbar{ display:none }

.beat{ animation:rise .26s cubic-bezier(.2,.9,.3,1) both }
@keyframes rise{ from{ opacity:0; transform:translateY(9px) } to{ opacity:1; transform:none } }

/* dialogue rows */
.row{ display:flex; gap:7px; align-items:flex-end; max-width:100% }
.row.right{ flex-direction:row-reverse }
/* The column holds the name label + the bubble. WITHOUT align-items:flex-start
   the bubble (a block) stretches to the column width, and the column is as wide
   as the NAME — so "Wait up!" under "Anton Saint Jean" wrapped to two lines for
   no reason. Measured: an 84px bubble for text that needs ~75px on one line.
   align-items lets each child size to its own content. */
.row > .col{ display:flex; flex-direction:column; align-items:flex-start; min-width:0; max-width:78% }
.row.right > .col{ align-items:flex-end }
.who{
  font-size:10.5px; opacity:.62; margin:0 0 3px 6px; letter-spacing:.3px;
  font-weight:600;
}
.row.right .who{ text-align:right; margin:0 6px 3px 0 }
.chipav{
  flex:0 0 auto; width:26px; height:26px; border-radius:50%;
  display:grid; place-items:center; font-size:9px; font-weight:800;
  color:rgba(255,255,255,.9); border:1.5px solid rgba(255,255,255,.28);
  background-size:cover; background-position:center top; margin-bottom:2px;
}
.bubble{
  max-width:100%; padding:9px 13px; border-radius:16px;
  /* break-word, NOT `anywhere`: `anywhere` also shrinks the INTRINSIC min-content
     width, so a shrink-to-fit bubble collapses to ~one character and every short
     line wraps ("Wait up!" measured 80px on two lines). */
  overflow-wrap:break-word;
  font-size:calc(14.5px * var(--text-scale)); line-height:1.44;
  /* .97, not .94. At .94 the plate behind tints the bubble: the same cream
     bubble read warm-tan over the jungle plate and cool-grey over the night
     hall, so the reader's own dialogue changed colour scene to scene. 3% is
     still enough to sit IN the painting rather than on top of it. */
  background:rgba(246,239,228,.97); color:var(--ink);
  border-bottom-left-radius:5px;
  box-shadow:0 2px 10px rgba(0,0,0,.32);
}
.row.right .bubble{
  background:var(--pov); border-bottom-left-radius:16px; border-bottom-right-radius:5px;
}
.bubble.think{ font-style:italic; background:rgba(246,239,228,.86) }
.row.right .bubble.think{ background:rgba(188,220,245,.88) }

/* narration — left-anchored in the queue like the dialogue it sits between
   (owner call 2026-07-26: centred action panels floated loose in the chat
   column; left-aligned they read as part of the conversation). */
.narrate{
  align-self:flex-start; max-width:88%; text-align:left;
  font-family:var(--serif); font-size:calc(14.5px * var(--text-scale));
  line-height:1.58; color:var(--cream);
  /* .72 not .62 — see the scrim note. A narration panel is the ONLY protection
     its text has, and the newest one sits on the brightest part of the plate. */
  background:rgba(12,10,8,.72); backdrop-filter:blur(7px);
  padding:11px 16px; border-radius:13px;
  border:1px solid rgba(255,255,255,.07);
  text-shadow:0 1px 3px rgba(0,0,0,.6);
  /* `pretty`, not `balance`: left-aligned prose only needs its orphan lines
     rescued — balance would shorten every line and leave the panel gappy. */
  text-wrap:pretty;
}
/* Two narration panels back to back were separated by the same 9px as two
   lines of the same conversation, so they merged into one wall of centred
   serif. Give a prose block its own air on both sides. */
.narrate + .narrate{ margin-top:4px }
.row + .narrate, .narrate + .row, .shot + .narrate, .narrate + .shot{ margin-top:5px }

/* the yellow objective card */
.notice{
  align-self:center; max-width:88%; text-align:center; overflow-wrap:break-word;
  background:var(--gold); color:#2b2007; font-weight:800;
  font-size:calc(14px * var(--text-scale)); line-height:1.36;
  padding:11px 16px; border-radius:11px;
  box-shadow:0 4px 16px rgba(242,193,78,.3);
  text-wrap:balance;
  /* The objective is the one card that answers "what am I even doing here" —
     it gets a single arriving glow and then sits still. No loop: this thing
     stays in the scrollback for the rest of the episode. */
  margin:6px 0;
}
/* Both animations, explicitly: `.notice` alone would have replaced the shared
   `.beat` rise and the card would have appeared without the arrival move. */
.beat.notice{
  animation:rise .26s cubic-bezier(.2,.9,.3,1) both, noticeIn 1.5s ease-out both;
}
@keyframes noticeIn{
  0%   { box-shadow:0 4px 16px rgba(242,193,78,.3) }
  22%  { box-shadow:0 4px 30px rgba(242,193,78,.62) }
  100% { box-shadow:0 4px 16px rgba(242,193,78,.3) }
}

/* time skip */
.timecard{
  align-self:center; font-size:11.5px; letter-spacing:2.6px; text-transform:uppercase;
  font-weight:700; color:rgba(246,239,228,.8);
  border-top:1px solid rgba(246,239,228,.22); border-bottom:1px solid rgba(246,239,228,.22);
  padding:7px 18px; margin:9px 0;
  text-shadow:0 1px 3px rgba(0,0,0,.75);
}

/* stat-change pill */
.pill{
  align-self:flex-start; display:flex; align-items:flex-start; gap:9px;
  background:rgba(28,25,21,.8); backdrop-filter:blur(7px);
  border:1px solid rgba(255,255,255,.1);
  border-radius:999px; padding:6px 15px 6px 6px; max-width:88%;
}
.pill .orb{
  flex:0 0 auto; width:28px; height:28px; border-radius:50%;
  display:grid; place-items:center; font-size:14px;
  background:rgba(108,174,122,.28); border:1px solid rgba(108,174,122,.5);
}
.pill.result .orb{ background:rgba(224,104,79,.26); border-color:rgba(224,104,79,.55) }
/* "Vanity" pill: acknowledges a choice without moving a stat. Deliberately
   quieter and gold rather than green — a reader who sees the stat-change
   treatment will hunt the HUD for a number that never changed. */
.pill.vanity{ background:rgba(28,25,21,.62); border-color:rgba(242,193,78,.32) }
.pill.vanity .orb{ background:rgba(242,193,78,.16); border-color:rgba(242,193,78,.45); color:var(--gold) }
.pill.vanity .txt{ color:rgba(242,193,78,.92); font-style:italic }
/* min-width:0 — a flex child will NOT shrink below its content without it, so
   the label ran straight out through the right edge of the pill on device. */
.pill .txt{ min-width:0; overflow-wrap:break-word; padding-top:4px; font-size:12.5px; font-weight:600; line-height:1.32; color:rgba(246,239,228,.94) }

/* Scene marker (quiet). It was the ONE thing in the stream with no panel and
   no shadow behind it — 50% cream sitting straight on the plate. On the lit
   jungle background in e03 ("THE WESTERN JUNGLE") it was effectively
   unreadable. It still has to stay quiet, so it gets a shadow and a whisper of
   a plate rather than a real card. */
.scenemark{
  align-self:center; font-size:10.5px; letter-spacing:1.6px; text-transform:uppercase;
  color:rgba(246,239,228,.72); padding:3px 12px; margin:7px 0;
  background:rgba(12,10,8,.42); border-radius:999px;
  text-shadow:0 1px 3px rgba(0,0,0,.85);
  backdrop-filter:blur(4px);
}

/* ── portrait bay ──────────────────────────────────────────────────── */
/* Spacer only. position:static on purpose — the portrait and rail anchor to
   #stage so they can be positioned against the MEASURED choice-sheet height
   instead of a percentage of this box, which guessed wrong on a taller phone. */
/* The choice sheet used to SNAP the conversation up: openSheet set the bay's
   flex-basis in one jump, so the stream lost ~100px of height in a single
   frame (owner, 2026-07-26: "doesn't feel seamless"). Glide it instead —
   vn.js keeps the newest line pinned to the bottom every frame while this
   transition runs, so the text rides the resize smoothly in both directions. */
.bay{ position:static; flex:0 0 34%; min-height:150px; pointer-events:none;
  transition:flex-basis .3s cubic-bezier(.2,.9,.3,1) }
.portrait{
  position:absolute; left:50%; bottom:40px; transform:translateX(-50%); z-index:3;
  width:44%; aspect-ratio:1; border-radius:50%;
  background:#2a2620 center top/cover no-repeat;
  border:3px solid rgba(246,239,228,.16);
  box-shadow:0 10px 34px rgba(0,0,0,.6);
  display:grid; place-items:center;
  font-size:30px; font-weight:800; color:rgba(255,255,255,.5); letter-spacing:1px;
  opacity:0; transition:opacity .2s ease, transform .22s ease, bottom .22s ease, width .22s ease;
}
.portrait.on{ opacity:1 }
/* While a choice is open the sheet owns the bottom of the stage — lift and
   shrink the portrait so the face clears it instead of being sliced in half.
   You still see who you're answering, which is the point of having it. */
/* Even on a tall phone the lift lands the portrait inside the bottom of the
   stream — a 4-option sheet is ~300px and the gap between the stream floor and
   the top of the sheet is ~16px, so there is nowhere for a circle to go. The
   bubbles are above it in z-order, which keeps the words readable, but at full
   strength the bright 3px ring got sliced by a bubble edge and the whole thing
   read as a rendering fault. Softened while choosing, it reads as what it is:
   the person you are answering, sitting behind the conversation. */
#stage.choosing .portrait{
  width:30%; bottom:calc(var(--sheet-h, 190px) + 12px);
  opacity:.8; border-color:rgba(246,239,228,.09);
}
#stage.choosing .rail{ bottom:calc(var(--sheet-h, 190px) + 30px); transition:bottom .22s ease, opacity .2s ease }
/* On a SHORT viewport the lift has nowhere to go. Measured at 390x620 with a
   four-option sheet: the sheet is 301px, the bay only reserves 211px, so
   lifting the portrait clear of the sheet parks a 117px face in the middle of
   the stream — directly on top of the two lines the choice is answering.
   There is no arrangement where the sheet, the last bubble and the portrait all
   fit in 620px, and of the three the portrait is the one that carries no words.
   It comes back the moment the choice is made.
   (The taller-phone case is NOT solved by this — see the review note: the sheet
   still overlaps the last ~90px of the stream there, which needs the bay to
   yield height and the stream to re-pin, i.e. a JS change.) */
@media (max-height: 700px){
  #stage.choosing .portrait,
  #stage.choosing .rail{ opacity:0 }
}
.portrait.pop{ animation:pop .16s ease }
@keyframes pop{ from{ transform:translateX(-50%) scale(.93) } to{ transform:translateX(-50%) scale(1) } }
.portrait .badge{
  position:absolute; right:1%; bottom:5%;
  width:40px; height:40px; border-radius:50%;
  background:rgba(20,17,14,.94); border:2px solid rgba(246,239,228,.38);
  display:grid; place-items:center; font-size:22px;
  box-shadow:0 2px 10px rgba(0,0,0,.55);
}
/* One clear beat when the MOOD changes (owner, 2026-07-26: the emotion was
   easy to miss). Fires only on an actual change — vn.js compares the emoji
   before re-arming the class — so a long same-mood exchange stays still. */
.portrait .badge.swap{ animation:badgeSwap .5s cubic-bezier(.2,.9,.3,1) }
@keyframes badgeSwap{ 0%{ transform:scale(1) } 45%{ transform:scale(1.5) } 100%{ transform:scale(1) } }
/* the supporting cast rail */
.rail{
  position:absolute; right:9px; bottom:58px; z-index:3;
  display:flex; flex-direction:column; gap:6px; transition:bottom .22s ease;
}
.rail .chipav{ width:32px; height:32px; font-size:10px; opacity:.72 }

/* ── choices ───────────────────────────────────────────────────────── */
.choices{
  position:absolute; left:0; right:0; bottom:0; z-index:8;
  padding:16px 14px calc(20px + env(safe-area-inset-bottom));
  display:flex; flex-direction:column; gap:8px;
  background:linear-gradient(180deg, transparent, rgba(6,5,4,.9) 26%);
  animation:rise .28s cubic-bezier(.2,.9,.3,1) both;
  /* A choice node with five surviving options (or two-line labels) grows
     upward with nothing stopping it and eats the stream. Cap it and let the
     sheet itself scroll; --sheet-h is measured off offsetHeight, so the
     portrait still lifts to the real, capped height. */
  max-height:64%; overflow-y:auto; overscroll-behavior:contain;
}
.choices::-webkit-scrollbar{ display:none }
.choices{ scrollbar-width:none }
.choices > .opt{ flex:0 0 auto }
.opt{
  appearance:none; width:100%; text-align:left; cursor:pointer;
  font-family:inherit; font-size:calc(14px * var(--text-scale)); line-height:1.4;
  color:var(--cream); padding:12px 15px; border-radius:13px;
  background:rgba(30,26,22,.9); border:1px solid rgba(246,239,228,.2);
  backdrop-filter:blur(8px); transition:transform .12s, background .12s, border-color .12s;
  display:flex; gap:9px; align-items:baseline;
}
.opt:hover:not(:disabled){ background:rgba(52,45,37,.95); border-color:rgba(246,239,228,.4); transform:translateY(-1px) }
.opt:active:not(:disabled){ transform:translateY(0) scale(.99) }
.opt .lead{ flex:0 0 auto; opacity:.85 }
.opt.act{ font-style:italic }
.opt.spoken .body::before{ content:"“" }
.opt.spoken .body::after{ content:"”" }
.opt.gem{ border-color:rgba(242,193,78,.6); background:rgba(48,38,18,.9) }
.opt .gem-tag{ margin-left:auto; flex:0 0 auto; color:var(--gold); font-weight:800; font-size:12px }
.opt .lock{ margin-left:auto; flex:0 0 auto; font-size:11px; font-weight:700; opacity:.85 }
/* A stat check the reader PASSES — same slot as the lock, but affirmative.
   Soft green so "this door is open because of your build" reads at a glance. */
.opt .req{ margin-left:auto; flex:0 0 auto; font-size:11px; font-weight:700; color:#a8cf96 }
/* A DISABLED option still has to explain itself. `opacity:.42` on the button
   was a GROUP opacity, so it multiplied down onto the one part the reader
   actually needs — the reason. "🔑 1 — none to spend" landed at .42 gold on a
   dark plate and "🔒 Intelligence 2" at .36, i.e. the least legible text in the
   sheet was the answer to "why can't I pick this?". Dim the label, not the
   reason: same "unavailable" read, the tag stays at full strength. */
.opt:disabled{ cursor:not-allowed; background:rgba(30,26,22,.6); border-color:rgba(246,239,228,.12) }
.opt.gem:disabled{ background:rgba(48,38,18,.6); border-color:rgba(242,193,78,.3) }
.opt:disabled .lead,
.opt:disabled .body{ opacity:.44 }
.opt:disabled .lock,
.opt:disabled .gem-tag{ opacity:.92 }

/* ── progress + version ────────────────────────────────────────────── */
/* Taken out of the flex column and pinned above the home indicator. In flow at
   the very bottom of a 100dvh stage it sat UNDER the indicator pill on any
   modern iPhone, where a 3px hairline is simply not there. */
.progress{
  position:absolute; left:0; right:0; bottom:env(safe-area-inset-bottom); z-index:6;
  height:4px; background:rgba(246,239,228,.14);
}
/* Gold, not #e0684f. That coral is the palette's NEGATIVE colour (the losing
   stat-pill orb); using it for "how far you've read" made progress read as a
   warning. Held at 78% so a hairline can't compete with the gold objective
   card or the gold CTA. */
.progress i{ display:block; height:100%; width:0; background:rgba(242,193,78,.78); transition:width .3s ease }
/* z-index 7, not 9. The build tag sat ABOVE the choice sheet (z-index 8), so
   "v0.5.0-b1 ll-e01" printed straight across the bottom option — at 390x620 it
   lands on the option's label. It still needs to clear the stream (4) and the
   progress hairline (6); it does not need to clear a sheet the reader is
   using. */
.ver{
  position:absolute; left:8px; bottom:calc(11px + env(safe-area-inset-bottom)); z-index:7;
  font-size:9.5px; letter-spacing:.4px; color:rgba(246,239,228,.3);
  pointer-events:none; font-variant-numeric:tabular-nums;
}
.tap-hint{
  position:absolute; right:10px; bottom:calc(10px + env(safe-area-inset-bottom)); z-index:6;
  /* The alpha lives in the ANIMATION, not in `color`. It used to be in both —
     .42 cream multiplied by the animation's .34 resting opacity — so the hint
     settled at ~14% effective and the only instruction the player ever gets
     for the core gesture became invisible after ~16 seconds. One source of
     alpha; it now rests at ~45% and peaks at ~81%. */
  font-size:10.5px; color:rgba(246,239,228,.9); pointer-events:none;
  text-shadow:0 1px 3px rgba(0,0,0,.8);
  /* Finite, not infinite. This is a 15-minute read; a label pulsing in the
     corner for the whole of it is a distraction that never turns off. Six
     breaths teaches the gesture, then it settles and stays quietly available. */
  animation:breathe 2.6s ease-in-out 6 both;
}
@keyframes breathe{ 0%,100%{opacity:.5} 50%{opacity:.9} }
/* FIRST-STEPS BECKON. During a reader's first five taps of an episode, three
   idle seconds make the hint slowly swell until they tap — the one moment
   "tap to continue" genuinely needs to be found. vn.js arms it (armBeckon)
   and never again after tap five; the reduced-motion block below kills it
   along with the breathe loop. */
.tap-hint.beckon{ animation:beckon 1.8s ease-in-out infinite alternate; transform-origin:bottom right }
@keyframes beckon{ from{ opacity:.6; transform:scale(1) } to{ opacity:1; transform:scale(1.35) } }

/* ── overlay cards (title / ending / settings / error) ─────────────── */
.sheet{
  position:absolute; inset:0; z-index:20; display:flex; flex-direction:column;
  align-items:center; justify-content:center; text-align:center; gap:14px;
  padding:34px 26px;
  /* A sheet taller than the stage must SCROLL, not clip — with the XP bar the
     stage lost 37px and the launch card's episode picker fell off the bottom
     (caught on a 375x812 QA pass, 2026-07-26). `safe center` keeps the
     centered look but refuses to clip the top; browsers without it fall back
     to plain center + scroll, which still reaches everything. */
  overflow-y:auto; justify-content:safe center;
  /* .955, not .9. The blur is what made .9 readable, and backdrop-filter is
     exactly the property that silently no-ops in older Android WebViews and in
     the Facebook / Instagram in-app browsers — where the launch copy would
     then be sitting on top of a fully legible story. Blur is now the bonus,
     not the mechanism. */
  background:rgba(6,5,4,.955); backdrop-filter:blur(10px);
  animation:fade .3s both;
  /* THE LAUNCH CARD DID NOT FIT. Measured at 390x620 (an in-app browser on a
     normal phone, once its chrome takes its cut): content 739px in a 620px
     box, no scroll anywhere in the tree — the episode picker was gone and on a
     shorter viewport "Begin" itself would have been unreachable. `safe center`
     keeps the card optically centred when it fits and falls back to top-
     aligned when it doesn't, which is the only way flex centring and
     scrolling coexist (the same trap already documented on .stream). */
  overflow-y:auto; overscroll-behavior:contain;
  justify-content:safe center;
  -webkit-overflow-scrolling:touch;
}
.sheet::-webkit-scrollbar{ display:none }
.sheet{ scrollbar-width:none }
.sheet > *{ flex:0 0 auto }
@keyframes fade{ from{opacity:0} to{opacity:1} }
/* Launch card wearing the project poster. The poster is a laid-out element
   rather than a background image: as a background it needed a scrim so the
   copy on top stayed legible, and that scrim fell straight across the painted
   "LOST LIBRARY" wordmark and greyed it out. Laid out for real, the copy sits
   below it and nothing needs darkening at all. */
.sheet.has-cover{
  justify-content:flex-start; gap:10px;
  padding:env(safe-area-inset-top) 24px calc(24px + env(safe-area-inset-bottom));
}
.sheet.has-cover .poster{
  width:100%; max-height:56%; object-fit:contain; object-position:center top;
  display:block; margin:0 0 4px;
}
/* The poster already says "The Lost Library" — a kicker repeating it is noise. */
.sheet.has-cover .kicker{ display:none }
.sheet.has-cover h1{ font-size:25px }
.sheet.has-cover .hook{ font-size:13.5px; line-height:1.45 }
.sheet.has-cover p{ font-size:13px }
.sheet h1{ font-family:var(--serif); font-size:30px; margin:0; line-height:1.15; text-wrap:balance }
.sheet h2{ font-family:var(--serif); font-size:21px; margin:0; color:var(--gold); text-wrap:balance }
.sheet .kicker{ font-size:11px; letter-spacing:2.4px; text-transform:uppercase; opacity:.6 }
.sheet p{ margin:0; font-size:14px; line-height:1.6; max-width:32ch; opacity:.9; text-wrap:pretty }
/* 700 → 600. Four lines of bold gold under the poster was the loudest thing on
   the launch card, out-shouting both the episode title above it and the gold
   CTA below — and gold on this card should belong to the button you press. */
.sheet .hook{ color:var(--gold); font-weight:600; font-size:15px; line-height:1.5; max-width:30ch; text-wrap:balance }

/* Short viewports — an in-app browser, a small phone, or a desktop window
   someone has dragged down. Shrink the poster before the card starts scrolling
   so the common case still fits in one screen. */
@media (max-height: 740px){
  .sheet{ padding:22px 22px }
  .sheet.has-cover{ gap:8px }
  .sheet.has-cover .poster{ max-height:44% }
  .sheet.has-cover h1{ font-size:21px }
  .sheet.has-cover .hook{ font-size:12.5px }
}
.btn{
  appearance:none; cursor:pointer; font-family:inherit; font-weight:800;
  font-size:15px; padding:13px 30px; border-radius:999px; border:0;
  background:var(--gold); color:#2b2007; transition:transform .12s;
}
.btn:hover{ transform:translateY(-1px) }
.btn.ghost{ background:transparent; color:var(--cream); border:1px solid rgba(246,239,228,.35); font-weight:600; font-size:13px; padding:10px 22px }
/* THE ACTIONS ARE A GROUP. The sheet's flat 14px gap gave the CTA exactly the
   same separation from the paragraph above it as two stacked buttons have from
   each other, so on the launch, locked and ending cards nothing read as "the
   thing to press" — just an evenly spaced list. Push the block down, pull the
   buttons together. (`.sheet.settings` keeps its own gap:0 layout and its
   buttons live inside `.set-actions`, so neither rule reaches it.) */
.sheet > .btn:first-of-type{ margin-top:7px }
.sheet > .btn + .btn{ margin-top:-5px }
.sheet .rating{
  font-size:10.5px; letter-spacing:1.6px; text-transform:uppercase;
  border:1px solid rgba(246,239,228,.3); border-radius:999px; padding:4px 12px; opacity:.7;
}
/* The LOCKED card reuses `.rating` for the price ("🔑 1 Boss Key to open").
   Worn at content-rating weight — 10.5px at 70% cream — the single fact the
   whole card exists to deliver read quieter than the paragraph under it.
   `#title` is the launch card (vn.js sets the id), so this variant lands only
   where `.rating` is carrying a price rather than an audience rating. */
.sheet:not(#title) .rating{
  opacity:1; color:var(--gold); font-weight:800; font-size:11.5px;
  border-color:rgba(242,193,78,.5); background:rgba(46,36,14,.6); padding:6px 15px;
}

/* settings */
.sheet.settings{
  justify-content:flex-start; text-align:left; align-items:stretch; gap:0;
  padding:calc(56px + env(safe-area-inset-top)) 26px calc(26px + env(safe-area-inset-bottom));
}
.settings h2{ text-align:center; margin-bottom:18px }
.set-row{
  display:flex; align-items:center; justify-content:space-between; gap:12px;
  padding:13px 2px; border-bottom:1px solid rgba(246,239,228,.1); font-size:14px;
}
.set-row .sub{ display:block; font-size:11.5px; opacity:.62; margin-top:3px; max-width:28ch; line-height:1.35 }
.sw{
  appearance:none; cursor:pointer; flex:0 0 auto; width:44px; height:25px; border-radius:999px;
  background:rgba(246,239,228,.18); border:0; position:relative; transition:background .18s;
}
.sw::after{
  content:""; position:absolute; top:3px; left:3px; width:19px; height:19px; border-radius:50%;
  background:var(--cream); transition:transform .18s;
}
.sw[aria-checked="true"]{ background:#6cae7a }
.sw[aria-checked="true"]::after{ transform:translateX(19px) }
/* margin-top:auto — the three rows only fill the top half of a phone screen,
   which left "Done" stranded mid-card above 400px of empty black AND out of
   easy thumb reach. Pushing the actions to the floor of the sheet fixes both. */
.set-actions{ display:flex; flex-direction:column; gap:9px; margin-top:auto; padding-top:26px; align-items:center }
/* "Restart episode" wipes the save. It should not look identical to "Copy log". */
.btn.ghost.danger{ color:rgba(224,104,79,.95); border-color:rgba(224,104,79,.42) }
.btn.ghost.danger:hover{ border-color:rgba(224,104,79,.8) }

@media (prefers-reduced-motion: reduce){
  *{ animation-duration:.01ms !important; transition-duration:.01ms !important }
  /* An INFINITE animation squeezed to .01ms doesn't stop — it re-samples a
     random point of the curve every frame, so the blanket rule above turned
     the tap hint into a flicker for precisely the people who asked for less
     motion. Stop it outright and leave the label legible. */
  .tap-hint{ animation:none !important; opacity:.5 }   /* matches the animation's resting value */
  .chip.bump, .portrait.pop{ animation:none !important }
  /* A cross-fade is not motion. Keeping it avoids a hard cut between plates,
     which is more jarring than the fade it replaces. */
  .bg-layer{ transition-duration:.35s !important }
}

/* ── in-stream still ────────────────────────────────────────────────
   A photo arriving in the thread. Left-aligned like a received message,
   sized as a bubble rather than a full-bleed insert, so it reads as part
   of the conversation instead of interrupting it. */
.shot{
  align-self:flex-start; margin:0; max-width:78%;
  border-radius:15px; overflow:hidden;
  border:1px solid rgba(246,239,228,.14);
  box-shadow:0 4px 18px rgba(0,0,0,.45);
  background:rgba(20,17,14,.5);
}
.shot img{ display:block; width:100%; height:auto }

/* ── stat tooltip (tap a chip) ───────────────────────────────────────
   `title` is useless on a phone — there is no hover — and the chips are
   four unlabelled emoji until you know them. */
.chip{ appearance:none; font-family:inherit; color:var(--cream); cursor:pointer }
.stat-tip{
  position:absolute; z-index:30; max-width:232px;
  background:rgba(20,17,14,.96); backdrop-filter:blur(8px);
  border:1px solid rgba(242,193,78,.34); border-radius:11px;
  padding:9px 12px; box-shadow:0 8px 26px rgba(0,0,0,.6);
  animation:rise .18s ease both;
}
/* A caret pointing back at the chip you tapped. Without it the bubble is
   clamped to the stage, so with four chips in a row it was genuinely ambiguous
   which one it was describing. --cx is the chip's centre, set when the tip is
   positioned; the fallback keeps it sane if it's ever missing. */
.stat-tip::before,
.stat-tip::after{
  content:""; position:absolute; left:var(--cx, 24px);
  width:0; height:0; border-left:7px solid transparent; border-right:7px solid transparent;
}
/* Two triangles: the outer one continues the gold hairline, the inner one
   fills it with the bubble's own colour, so the caret reads as part of the
   card rather than as a loose chevron floating above it. */
.stat-tip::before{ top:-7px; margin-left:-7px; border-bottom:7px solid rgba(242,193,78,.34) }
.stat-tip::after { top:-6px; margin-left:-7px; border-bottom:7px solid rgba(20,17,14,.96) }
.stat-tip .st-head{ font-size:12.5px; font-weight:800; color:var(--gold); margin-bottom:3px }
.stat-tip .st-body{ font-size:12px; line-height:1.42; color:rgba(246,239,228,.86) }

/* ── ending-card tally ───────────────────────────────────────────────
   The end-of-episode stat readout used to be one joined string
   ("🤝 Charisma 1   🧠 Intelligence 1   💪 Strength 4"), which wrapped
   mid-item and orphaned "Strength 4" onto its own line — and gave no visual
   tie back to the chips the reader watched all episode. Same chip language,
   laid out as a row. */
.tally{ display:flex; flex-wrap:wrap; gap:7px; justify-content:center; margin:2px 0 4px }
.tally .t{
  display:flex; align-items:center; gap:6px;
  background:rgba(20,17,14,.62); border:1px solid rgba(255,255,255,.09);
  border-radius:999px; padding:5px 12px 5px 9px; font-size:12px; font-weight:600;
  color:rgba(246,239,228,.9); white-space:nowrap;
}
.tally .t b{ color:var(--gold); font-weight:800; font-variant-numeric:tabular-nums }

/* ── episode picker on the launch card ─────────────────────────────── */
.eppick{ display:flex; gap:8px; justify-content:center }
.eppick .ep{
  position:relative;
  appearance:none; cursor:pointer; font-family:inherit; font-weight:800; font-size:13px;
  width:34px; height:34px; border-radius:50%;
  background:transparent; color:rgba(246,239,228,.65);
  border:1px solid rgba(246,239,228,.28); transition:all .15s;
}
.eppick .ep:hover{ border-color:var(--gold); color:var(--gold) }
.eppick .ep.on{ background:var(--gold); border-color:var(--gold); color:#2b2007 }

/* Boss Keys chip — gold, so it reads as spendable rather than as another stat. */
.chip.keychip{
  border-color:rgba(242,193,78,.38);
  background:rgba(46,36,14,.62);
}
.chip.keychip .val{ color:var(--gold) }

/* "Boss Key earned" on the ending card — a RECEIPT, not an action.
   At 13.5px/800 in a gold pill it was the same size, shape and colour family as
   the gold "next episode" button sitting directly under it, so the ending card
   stacked three gold pills in a row and the eye had nowhere to land. It also
   invited a tap on something that does nothing. Same information, stated
   quietly: no fill, no bold-gold shout, and it sits closer to the tally it
   belongs with than to the buttons below. */
.keywon{
  display:inline-flex; align-items:center; gap:7px; align-self:center;
  background:transparent; border:1px solid rgba(242,193,78,.34);
  border-radius:999px; padding:5px 14px;
  color:rgba(242,193,78,.92); font-weight:700; font-size:12px; letter-spacing:.2px;
  margin-top:-6px;
}

/* Key cost riding on the "next episode" button. `rgba(43,32,7,.22)` over gold
   was a ~4% tonal shift — the chip was invisible and the 🔑 read as a smudge
   in the middle of the label. Make it a real inset chip. */
.btn .keycost{
  margin-left:9px; padding:2px 9px; border-radius:999px;
  background:rgba(43,32,7,.42); color:rgba(255,244,220,.96);
  font-size:12px; font-weight:800;
}

/* ── the END card (owner, 2026-07-26: "more flare") ─────────────────── */
/* The one sheet the topbar SURVIVES — the key chip + stat meters stay on
   top of an ending. The :not() keeps the general hide-rule in charge the
   moment any other sheet (settings) stacks on top of the end card. */
#stage:has(.sheet.end):not(:has(.sheet:not(.end))) .topbar{ visibility:visible; z-index:22 }
/* Journey rows make this card taller than a phone screen — top-anchor and
   scroll instead of center-and-clip, and clear the now-visible topbar. */
.sheet.end{
  justify-content:flex-start; overflow-y:auto;
  padding-top:64px; padding-bottom:calc(30px + env(safe-area-inset-bottom));
  gap:12px;
}
/* Staggered arrival, top to bottom — the ending LANDS instead of appearing.
   Finite animations, so the reduced-motion blanket collapses them cleanly. */
.sheet.end > *{ animation:endIn .5s cubic-bezier(.2,.9,.3,1) both }
.sheet.end > *:nth-child(1){ animation-delay:.05s }
.sheet.end > *:nth-child(2){ animation-delay:.14s }
.sheet.end > *:nth-child(3){ animation-delay:.24s }
.sheet.end > *:nth-child(4){ animation-delay:.36s }
.sheet.end > *:nth-child(5){ animation-delay:.48s }
.sheet.end > *:nth-child(6){ animation-delay:.58s }
.sheet.end > *:nth-child(7){ animation-delay:.68s }
.sheet.end > *:nth-child(8){ animation-delay:.76s }
.sheet.end > *:nth-child(9){ animation-delay:.84s }
@keyframes endIn{ from{ opacity:0; transform:translateY(16px) } to{ opacity:1; transform:none } }
/* The ending's name in the story's serif, lit gold-to-violet. */
.sheet .end-title{
  font-family:var(--serif); font-size:30px; line-height:1.15; margin:0;
  background:linear-gradient(115deg, #f2c14e 15%, #f6efe4 48%, #b58ef0 85%);
  -webkit-background-clip:text; background-clip:text; color:transparent;
  filter:drop-shadow(0 3px 18px rgba(242,193,78,.28));
}
/* The path you walked — the episode's Decide axes told back to the reader. */
.journey{ width:100%; max-width:340px; display:flex; flex-direction:column; gap:8px; margin:4px 0 2px }
.j-head{ font-size:10.5px; letter-spacing:2.4px; text-transform:uppercase; color:var(--gold); opacity:.9; text-align:center }
.j-row{
  text-align:left; background:rgba(246,239,228,.05);
  border:1px solid rgba(246,239,228,.1); border-left:3px solid rgba(242,193,78,.75);
  border-radius:11px; padding:10px 13px;
}
.j-q{ font-size:10.5px; letter-spacing:1.4px; text-transform:uppercase; opacity:.55; margin-bottom:4px }
.j-a{ font-size:13px; line-height:1.52; opacity:.94 }
/* The way home reads gold — an exit, not a danger. */
.btn.ghost.home{ border-color:rgba(242,193,78,.4); color:rgba(242,193,78,.95) }

/* A premium choice you already own — no price, just the mark that it's yours. */
.opt.gem.owned{ border-color:rgba(242,193,78,.4); background:rgba(40,33,16,.85) }
.opt.gem.owned .gem-tag{ opacity:.8; font-weight:700 }

/* The entry price, stated on the launch card before you commit. */
.sheet .price{
  font-size:12.5px; font-weight:800; letter-spacing:.2px;
  color:var(--gold); background:rgba(46,36,14,.55);
  border:1px solid rgba(242,193,78,.34); border-radius:999px; padding:6px 14px;
}
.sheet .price.owned{ color:rgba(246,239,228,.7); border-color:rgba(246,239,228,.2); background:rgba(20,17,14,.5); font-weight:600 }
.btn .keycost.short{ background:rgba(43,32,7,.42); opacity:.85 }
