
:root {
  --ink: #161914;
  --ink-soft: #20241d;
  --paper: #e5dfcf;
  --amber: #b9a365;
  --earth: #465446;
  --studio: #9ca590;
  --film-deep: #283026;
  --studio-edge: #7c846f;
  --patreon-edge: #667360;
  --line: rgba(229, 223, 207, 0.24);
}

* { box-sizing: border-box; }
[hidden] { display: none !important; }
.honeypot { position: absolute !important; left: -9999px !important; width: 1px !important; height: 1px !important; overflow: hidden !important; }
/* scrollbar-gutter reserves the scrollbar's space permanently, so hiding
   page scroll (which opening a book does, via body.style.overflow) cannot
   widen the layout. Without it the page grew by the scrollbar's width the
   instant a book opened, the shelf reflowed under the already-drawn candle
   glow canvases, and every light overlay ended up offset from its book —
   reported on Edge, but latent in any browser with classic scrollbars. */
html { scroll-behavior: smooth; background: var(--ink); scrollbar-gutter: stable; }
body { margin: 0; background: var(--ink); color: var(--paper); font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; overflow-x: hidden; animation: pageEnter .38s ease both; transition: opacity .22s ease; }
html.page-leaving body { opacity: 0; }
@keyframes pageEnter { from { opacity: 0; } }
button, input { font: inherit; }
a { color: inherit; text-decoration: none; }
::selection { background: var(--amber); color: var(--ink); }

/* Custom scrollbar, site-wide default: a thin amber pill floating with no
   visible track, in keeping with the rest of the site's thin-hairline,
   nothing-extraneous chrome (.section-rule, .hero-footer border, etc).
   The 3px transparent border + background-clip:padding-box is what gives
   the thumb its inset "floating" look — since the border itself paints
   transparent, whatever sits behind it (the track, itself left transparent
   so the page's own background shows through) bleeds through as padding.
   That means this works unmodified against any background color, which
   matters because .book-modal below reuses the exact same technique
   against a background that changes per-book. Firefox has no padding-box
   trick for scrollbar-color, so it just gets a plain thin thumb via the
   standard property. */
html { scrollbar-width: thin; scrollbar-color: rgba(185,163,101,.45) transparent; }
::-webkit-scrollbar { width: 12px; height: 12px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background-color: rgba(185,163,101,.4); border: 3px solid transparent; border-radius: 999px; background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background-color: rgba(185,163,101,.68); }
::-webkit-scrollbar-corner { background: transparent; }

/* Uses its own pre-graded asset (film-grain-graded.webp), not the plain
   film-grain.webp that .modal-grain below still uses — this used to be
   the plain asset plus a live `filter: contrast(280%) brightness(103%)`,
   recomputed on every animation frame, on every page of the site, on top
   of an already fairly heavy mix-blend-mode:overlay layer. That contrast/
   brightness bump is now baked directly into film-grain-graded.webp
   instead (same formula, applied once, offline), so this element no
   longer needs a runtime `filter` at all — same look, one less expensive
   operation running in a loop forever. .modal-grain intentionally keeps
   using the original asset plus its own separate filter recipe (245%/
   105%, different from this one), so it's untouched by this change. */
.film-grain { position: fixed; inset: -12%; z-index: 100; pointer-events: none; opacity: .27; background: url('/assets/film-grain-graded.webp') center / 1280px 720px repeat; animation: grain .72s steps(2) infinite; mix-blend-mode: overlay; will-change: transform; }
@keyframes grain { 0% { transform: translate3d(-1.8%,1.2%,0) scale(1.02); } 25% { transform: translate3d(1.4%,-1.1%,0) scale(1.025); } 50% { transform: translate3d(.9%,1.7%,0) scale(1.02); } 75% { transform: translate3d(-1.2%,-1.5%,0) scale(1.025); } 100% { transform: translate3d(-1.8%,1.2%,0) scale(1.02); } }
.site-header { position: absolute; z-index: 20; top: 0; left: 0; width: 100%; display: grid; grid-template-columns: 1fr auto 1fr; align-items: center; padding: 22px clamp(20px,3.5vw,58px); border-bottom: 1px solid rgba(255,255,255,.28); color: #f2eee2; text-transform: uppercase; letter-spacing: .14em; font-size: 10px; }
.brand-lockup, .footer-brand { display: flex; align-items: center; gap: 10px; font-weight: 650; }
.brand-lockup img { width: 27px; height: 27px; object-fit: contain; }
.main-nav { display: flex; align-items: center; justify-content: center; gap: clamp(18px,3vw,42px); }
.main-nav > a, .nav-item > button { padding: 8px 0; border: 0; background: none; color: inherit; text-transform: uppercase; letter-spacing: .14em; font-size: 10px; cursor: pointer; opacity: .76; transition: opacity .2s ease,color .2s ease; }
.main-nav > a:hover, .nav-item > button:hover, .nav-item.open > button { opacity: 1; color: #fff4d8; }
.nav-item { position: relative; }
.caret { display: inline-block; margin-left: 4px; font-size: 8px; transition: transform .22s ease; }
.nav-item.open .caret, .nav-item:hover .caret { transform: rotate(180deg); }
.dropdown { position: absolute; top: calc(100% + 10px); left: 50%; min-width: 185px; padding: 8px; border-radius: 12px; background: rgba(22,25,20,.94); box-shadow: 0 22px 50px rgba(0,0,0,.3); backdrop-filter: blur(16px); transform: translateX(-50%) translateY(-6px); opacity: 0; visibility: hidden; transition: opacity .2s ease,transform .2s ease,visibility .2s; }
.dropdown a { display: block; padding: 13px 15px; border-radius: 8px; letter-spacing: .11em; white-space: nowrap; transition: background .2s ease,color .2s ease; }
.dropdown a:hover { background: rgba(229,223,207,.09); color: #fff4d8; }
.has-dropdown:hover .dropdown, .has-dropdown:focus-within .dropdown, .nav-item.open .dropdown { opacity: 1; visibility: visible; transform: translateX(-50%) translateY(0); }
.header-cta { justify-self: end; display: flex; align-items: center; gap: 9px; padding: 10px 14px; border-radius: 10px; background: var(--amber); color: var(--ink); font-weight: 700; box-shadow: 0 10px 30px rgba(0,0,0,.16); transition: transform .22s ease,background .22s ease; }
.header-cta:hover { transform: translateY(-2px); background: #d0bc7d; }
.header-cta svg, .patreon-link svg, .community-link svg, .social-links svg { width: 17px; height: 17px; transition: transform .25s ease; }
.header-cta:hover svg { transform: translateX(3px); }

.hero { position: relative; min-height: 100svh; isolation: isolate; overflow: hidden; display: flex; align-items: flex-end; margin-bottom: -2px; padding: 120px clamp(20px,3.5vw,58px) clamp(28px,5vw,70px); }
.hero-image { position: absolute; inset: -4%; z-index: -4; background: url('/assets/hero-steve-pup.webp') center 54% / cover no-repeat; filter: saturate(.78) contrast(1.04); animation: heroDrift 26s ease-in-out infinite; will-change: transform; }
.hero-video { position: absolute; inset: -4%; z-index: -3; width: 108%; height: 108%; display: block; object-fit: cover; opacity: 1; background: transparent; filter: saturate(.78) contrast(1.04); animation: heroDrift 26s ease-in-out infinite; transition: opacity 1.1s ease; will-change: transform; }
.hero-video.ready { opacity: 1; }
@keyframes heroDrift { 0%,100% { transform: scale(1.015); } 50% { transform: scale(1.095); } }
.hero-shade { position: absolute; inset: 0; z-index: -2; background: linear-gradient(180deg,rgba(8,11,11,.25) 0%,rgba(9,12,11,.04) 34%,rgba(8,9,7,.82) 100%), linear-gradient(90deg,rgba(7,8,6,.24),transparent 45%,rgba(7,8,6,.12)); }
.hero::after { content: ""; position: absolute; inset: auto 0 -3px; height: 58%; z-index: -1; background: linear-gradient(180deg,transparent 0%,rgba(22,25,20,.18) 27%,rgba(22,25,20,.68) 63%,var(--ink) 88%,var(--ink) 100%); box-shadow: inset 0 -90px 100px rgba(22,25,20,.48); pointer-events: none; }
.hero-content { width: 100%; }
.eyebrow { margin: 0; text-transform: uppercase; letter-spacing: .2em; font-size: 10px; font-weight: 600; }
.hero-eyebrow { display: flex; align-items: center; gap: 14px; margin-bottom: 20px; opacity: .8; animation: rise .9s .1s both; }
.hero-eyebrow span { width: 3px; height: 3px; background: var(--amber); border-radius: 50%; }
.hero h1 { margin: 0; font-family: "Iowan Old Style","Baskerville","Times New Roman",serif; font-size: clamp(74px,13.7vw,212px); line-height: .69; letter-spacing: -.07em; font-weight: 400; text-transform: uppercase; text-shadow: 0 4px 40px rgba(0,0,0,.18); }
.hero h1 span { display: block; animation: titleReveal 1.1s cubic-bezier(.2,.76,.2,1) both; }
.hero h1 span:last-child { margin-left: 11.2vw; animation-delay: .08s; }
@keyframes titleReveal { from { opacity: 0; transform: translateY(70px); filter: blur(8px); } }
@keyframes rise { from { opacity: 0; transform: translateY(15px); } }
.hero-footer { margin-top: clamp(34px,6vw,80px); display: flex; justify-content: space-between; align-items: flex-end; border-top: 1px solid rgba(255,255,255,.35); padding-top: 18px; }
.hero-footer > p { margin: 0; max-width: 420px; font-family: "Iowan Old Style","Baskerville",serif; font-size: clamp(18px,2vw,29px); letter-spacing: -.01em; }
.scroll-cue { display: flex; align-items: center; gap: 14px; text-transform: uppercase; font-size: 9px; letter-spacing: .19em; }
.scroll-line { display: inline-block; width: 70px; height: 1px; background: currentColor; transform-origin: right; animation: pulseLine 2.2s infinite; }
@keyframes pulseLine { 50% { transform: scaleX(.35); opacity: .45; } }
.frame-number { position: absolute; top: 50%; right: -9px; transform: rotate(90deg); font: 9px monospace; letter-spacing: .25em; opacity: .55; }

.film-section { padding: clamp(28px,3.6vw,58px) clamp(20px,6vw,92px) clamp(75px,10vw,150px); background: linear-gradient(180deg,var(--ink) 0%,#1b211a 46%,var(--film-deep) 100%); }
.section-rule { display: flex; justify-content: space-between; border-top: 1px solid var(--line); padding-top: 12px; text-transform: uppercase; font: 9px/1.2 monospace; letter-spacing: .18em; color: rgba(230,223,205,.62); }
.film-heading { display: grid; grid-template-columns: 1.5fr .6fr; gap: 8vw; align-items: end; margin: clamp(55px,7vw,100px) 0 42px; }
.amber { color: var(--amber); }
.film-heading h2, .manifesto h2, .newsletter h2, .patreon-copy h2, .community-card h2, .privacy-modal h2, .page-heading h1, .thanks-heading h1, .download-title, .popup-title { font-family: "Iowan Old Style","Baskerville","Times New Roman",serif; font-weight: 400; letter-spacing: -.05em; }
.film-heading h2 { margin: 7px 0 0; font-size: clamp(54px,8.8vw,137px); line-height: .92; text-transform: uppercase; }
.film-logline { margin: 0 0 6px; max-width: 410px; font-family: "Iowan Old Style","Baskerville",serif; font-size: clamp(18px,2vw,28px); line-height: 1.25; color: rgba(230,223,205,.72); }
.film-poster { position: relative; display: block; width: 100%; aspect-ratio: 16/9; padding: 0; border: 0; border-radius: clamp(13px,1.6vw,22px); overflow: hidden; background: #050505; color: var(--paper); cursor: pointer; text-align: left; box-shadow: 0 40px 90px rgba(0,0,0,.28); }
.film-poster img { width: 100%; height: 100%; display: block; object-fit: cover; transition: transform 1.2s cubic-bezier(.2,.7,.2,1),filter .6s ease; filter: saturate(.74) contrast(.98) brightness(.82); }
.poster-wash { position: absolute; inset: 0; background: linear-gradient(180deg,rgba(0,0,0,.04),rgba(0,0,0,.35)); }
.film-poster:hover img { transform: scale(1.018); filter: saturate(.88) contrast(1) brightness(.9); }
.play-button { position: absolute; inset: 50% auto auto 50%; transform: translate(-50%,-50%); width: clamp(68px,7vw,106px); height: clamp(68px,7vw,106px); border-radius: 42%; display: grid; place-items: center; color: var(--ink); background: var(--amber); box-shadow: 0 10px 30px rgba(0,0,0,.25); transition: transform .35s ease,border-radius .35s ease; }
.play-button svg { width: 42%; }
.play-button { font-size: clamp(25px,3vw,40px); }
.film-poster:hover .play-button { transform: translate(-50%,-50%) scale(1.08) rotate(2deg); border-radius: 50%; }
.poster-caption { position: absolute; inset: auto 24px 20px; display: flex; justify-content: space-between; font: 9px monospace; text-transform: uppercase; letter-spacing: .18em; }
.poster-corners { position: absolute; inset: 13px; border: 1px solid rgba(255,255,255,.27); border-radius: 9px; pointer-events: none; }
.film-credit-line { display: flex; justify-content: space-between; gap: 20px; margin-top: 16px; font: 9px monospace; text-transform: uppercase; letter-spacing: .13em; color: rgba(230,223,205,.5); }

.manifesto { background: linear-gradient(180deg,var(--studio-edge) 0%,var(--studio) 48%,var(--patreon-edge) 100%); color: var(--ink); padding: clamp(75px,10vw,150px) clamp(20px,6vw,92px); }
.manifesto .section-rule { border-color: rgba(21,22,18,.28); color: rgba(21,22,18,.65); }
.manifesto-grid { display: grid; grid-template-columns: 1.45fr .55fr; gap: 8vw; margin-top: clamp(65px,9vw,130px); }
.manifesto h2 { margin: 0; font-size: clamp(49px,7.5vw,112px); line-height: .93; }
.manifesto h2 em { color: #f1ead8; font-weight: 400; text-shadow: 0 8px 28px rgba(35,42,33,.18); }
.manifesto-copy { padding-top: 10px; }
.manifesto-copy p { margin: 0 0 28px; font-size: clamp(16px,1.4vw,20px); line-height: 1.55; }
.manifesto-copy p:last-child { color: rgba(21,22,18,.62); }
.process-strip { display: grid; grid-template-columns: repeat(4,1fr); gap: 1px; margin-top: clamp(70px,10vw,140px); border: 0; border-radius: 12px; overflow: hidden; box-shadow: 0 12px 26px rgba(21,22,18,.14); }
.process-strip span { background: rgba(156,165,144,.8); padding: 20px; text-transform: uppercase; letter-spacing: .14em; font-size: 10px; display: flex; justify-content: space-between; backdrop-filter: blur(8px); }
.process-strip b { font-family: monospace; font-weight: 400; opacity: .5; }

.newsletter { padding: clamp(65px,9vw,130px) clamp(20px,6vw,92px); background: linear-gradient(180deg,var(--film-deep) 0%,#4c5444 58%,var(--studio-edge) 100%); }
.newsletter-card { position: relative; isolation: isolate; overflow: hidden; min-height: 570px; display: grid; grid-template-columns: 1fr 1fr; gap: 8vw; align-items: end; padding: clamp(32px,5vw,74px); border: 0; border-radius: clamp(18px,2.4vw,32px); background: var(--earth); color: #eee6d4; box-shadow: 0 12px 28px rgba(8,12,9,.24), inset 0 0 110px rgba(18,26,19,.2); transition: transform .38s cubic-bezier(.2,.7,.2,1),box-shadow .38s ease; }
.newsletter-card > * { position: relative; z-index: 2; }
.newsletter h2 { margin: 16px 0 28px; font-size: clamp(60px,8vw,120px); line-height: .78; }
.newsletter-copy > p:not(.eyebrow) { max-width: 460px; margin: 0; line-height: 1.55; color: rgba(241,223,188,.78); }
.privacy-link { margin-top: 22px; padding: 0 0 5px; border: 0; border-bottom: 1px solid rgba(241,223,188,.55); background: transparent; color: rgba(241,223,188,.78); text-transform: uppercase; letter-spacing: .16em; font-size: 9px; cursor: pointer; }
.privacy-link:hover { color: #fff3d7; border-color: currentColor; }
.signup-form { padding-bottom: 6px; }
.signup-form label { display: block; margin-bottom: 12px; text-transform: uppercase; font: 9px monospace; letter-spacing: .2em; }
.field-block { margin-bottom: 34px; }
.field-block input { width: 100%; border: 0; border-bottom: 1px solid rgba(241,223,188,.75); outline: 0; background: transparent; color: #fff3d7; font-family: "Iowan Old Style","Baskerville",serif; font-size: clamp(22px,2.4vw,34px); padding: 15px 6px 15px 0; }
.field-block input::placeholder { color: rgba(241,223,188,.38); }
.input-row { display: grid; grid-template-columns: 1fr auto; border-bottom: 1px solid rgba(241,223,188,.75); }
.input-row input { min-width: 0; border: 0; outline: 0; background: transparent; color: #fff3d7; font-family: "Iowan Old Style","Baskerville",serif; font-size: clamp(22px,2.4vw,34px); padding: 15px 6px 15px 0; }
.input-row input::placeholder { color: rgba(241,223,188,.38); }
.input-row button { border: 0; color: var(--ink); background: var(--amber); border-radius: 10px 10px 0 0; padding: 0 20px; display: flex; align-items: center; gap: 12px; text-transform: uppercase; letter-spacing: .14em; font-size: 9px; cursor: pointer; }
.input-row button:disabled { opacity: .65; cursor: wait; }
.input-row button svg { width: 17px; }
.form-note { min-height: 18px; margin: 12px 0 0; font-size: 10px; letter-spacing: .06em; color: rgba(241,223,188,.6); }
.form-note.success { color: #f7c967; }
.form-note.error { color: #ffd8cf; }
.confirmation-state { align-self: end; padding: clamp(26px,3vw,42px); border: 1px solid rgba(241,223,188,.28); border-radius: 18px; background: rgba(28,31,24,.2); }
.confirmation-state h3 { margin: 14px 0 18px; font: 400 clamp(42px,5.2vw,76px)/.9 "Iowan Old Style","Baskerville",serif; letter-spacing: -.045em; }
.confirmation-state > p:not(.eyebrow) { max-width: 500px; margin: 0 0 22px; font-size: 17px; line-height: 1.55; }
.confirmation-state small { display: block; color: rgba(241,223,188,.62); line-height: 1.5; }
.patreon-section { display: grid; grid-template-columns: .85fr 1.15fr; min-height: 720px; background: linear-gradient(180deg,var(--patreon-edge) 0%,#405044 48%,#26332c 100%); color: #f1ede2; padding: clamp(70px,10vw,145px) clamp(20px,8vw,122px); align-items: center; gap: 10vw; overflow: hidden; }
.patreon-credits { justify-self: center; align-self: start; margin-top: 52px; width: min(31vw,410px); background: var(--ink); color: #f1ede2; }
#support .patreon-credits { min-height: 0; box-shadow: 0 13px 30px rgba(8,12,9,.25), inset 0 0 90px 40px #26332c; }
#support .patreon-credits:hover { box-shadow: 0 28px 52px rgba(8,12,9,.15), inset 0 0 90px 40px #26332c; }
.patreon-credits > div { display: flex; flex-direction: column; align-items: center; gap: 18px; }
.patreon-credits-label { color: var(--amber); }
.patreon-watermark { width: min(70%,300px); top: 12%; right: -12%; opacity: .09; filter: blur(.75px); mix-blend-mode: multiply; }
.patreon-marquee { width: 100%; height: clamp(280px,32vw,400px); position: relative; overflow: hidden; pointer-events: none; user-select: none; opacity: .85; mask-image: linear-gradient(180deg,transparent,#000 10%,#000 90%,transparent); }
.patreon-marquee-track { display: flex; flex-direction: column; align-items: center; gap: 20px; animation: patreonMarqueeScroll 50s linear infinite; }
.patreon-marquee-name { white-space: nowrap; text-align: center; text-transform: uppercase; letter-spacing: .16em; font-size: 12px; font-weight: 550; color: #f1ede2; }
@keyframes patreonMarqueeScroll { from { transform: translateY(0); } to { transform: translateY(-50%); } }
@media (prefers-reduced-motion: reduce) { .patreon-marquee-track { animation: none; } }
.patreon-copy h2 { margin: 16px 0 30px; font-size: clamp(64px,8vw,118px); line-height: .8; }
.patreon-copy > p:not(.eyebrow) { max-width: 520px; line-height: 1.6; font-size: 17px; color: rgba(241,237,226,.78); }
.patreon-link { display: inline-flex; align-items: center; gap: 14px; margin-top: 34px; border-bottom: 1px solid currentColor; padding: 0 0 10px; text-transform: uppercase; letter-spacing: .16em; font-size: 10px; font-weight: 650; }
.patreon-link:hover svg, .community-link:hover svg, .social-links a:hover svg { transform: translate(3px,-3px); }

.community-section { display: grid; grid-template-columns: 1fr 1fr; background: linear-gradient(180deg,#26332c 0%,#48574a 28%,#273129 100%); padding: clamp(72px,9vw,138px) clamp(20px,6vw,92px); gap: clamp(18px,2vw,30px); }
.community-card { position: relative; isolation: isolate; min-height: 620px; display: flex; flex-direction: column; justify-content: space-between; overflow: hidden; padding: clamp(30px,4.5vw,66px); border: 0; border-radius: clamp(20px,2.3vw,34px); box-shadow: 0 13px 30px rgba(8,12,9,.25); transition: transform .38s cubic-bezier(.2,.7,.2,1),box-shadow .38s ease; }
.community-card::after { content: ""; position: absolute; z-index: 0; inset: 0; pointer-events: none; background: radial-gradient(circle at 88% 10%,rgba(255,255,255,.11),transparent 30%),linear-gradient(145deg,transparent 50%,rgba(0,0,0,.11)); }
.glow-card::before { content: ""; position: absolute; z-index: 1; inset: 0; pointer-events: none; background: radial-gradient(180px circle at var(--glow-x,50%) var(--glow-y,50%),rgba(255,245,217,.115),transparent 68%); opacity: 0; transition: opacity .3s ease; }
.community-card > div, .community-card > small { position: relative; z-index: 2; }
.card-watermark { position: absolute; z-index: 0; top: 7%; right: -7%; width: min(58%,360px); opacity: .075; filter: blur(.75px); pointer-events: none; user-select: none; mix-blend-mode: multiply; }
.discord-watermark { width: min(54%,340px); top: 10%; right: -3%; filter: invert(1) blur(.75px); opacity: .065; mix-blend-mode: screen; }
.runway-card { background: #76816d; color: #161914; }
.discord-card { background: #243329; color: #e5dfcf; }
.community-number { position: absolute !important; top: 24px; right: 28px; font: 9px monospace; letter-spacing: .18em; opacity: .48; }
.community-card h2 { margin: 18px 0 26px; max-width: 680px; font-size: clamp(54px,6.6vw,100px); line-height: .82; }
.community-card p:not(.eyebrow) { max-width: 520px; margin: 0; font-size: 16px; line-height: 1.6; opacity: .77; }
.community-actions { position: relative; z-index: 1; display: flex; align-items: flex-end; justify-content: space-between; gap: 26px; margin-top: 55px; }
.community-link { display: inline-flex; align-items: center; gap: 14px; border-bottom: 1px solid currentColor; padding: 0 0 10px; text-transform: uppercase; letter-spacing: .16em; font-size: 10px; font-weight: 650; white-space: nowrap; }
.code-button { display: flex; flex-direction: column; gap: 8px; min-width: 235px; padding: 16px 18px; border: 0; border-radius: 12px; background: rgba(229,223,207,.18); color: inherit; text-align: left; cursor: pointer; }
.code-button span { text-transform: uppercase; letter-spacing: .17em; font: 8px monospace; opacity: .58; }
.code-button strong { font: 600 13px monospace; letter-spacing: .09em; }
.affiliate-note { position: relative; z-index: 1; display: block; margin-top: 22px; max-width: 440px; font-size: 9px; line-height: 1.5; letter-spacing: .05em; opacity: .55; }

footer { position: relative; padding: 48px clamp(20px,3.5vw,58px) 30px; background: #10110e; display: grid; grid-template-columns: 1fr auto 1fr; align-items: end; gap: 30px; }
.footer-brand { text-transform: uppercase; letter-spacing: .16em; font-size: 10px; }
.footer-brand img { width: 34px; height: 34px; object-fit: contain; }
.social-links { display: flex; gap: 34px; }
.social-links a { display: flex; gap: 9px; align-items: center; text-transform: uppercase; letter-spacing: .15em; font-size: 11px; }
.footer-meta { justify-self: end; display: flex; flex-direction: column; text-align: right; gap: 6px; font: 8px monospace; text-transform: uppercase; letter-spacing: .15em; color: rgba(230,223,205,.45); }
.footer-contact { grid-column: 1; justify-self: start; padding: 0 0 6px; border: 0; border-bottom: 1px solid rgba(229,223,207,.32); background: transparent; color: rgba(229,223,207,.58); text-transform: uppercase; letter-spacing: .16em; font: 8px monospace; cursor: pointer; transition: color .2s ease,border-color .2s ease; }
.footer-contact:hover { color: var(--paper); border-color: currentColor; }
.minecraft-disclaimer { grid-column: 1 / -1; margin: 8px 0 0; padding-top: 22px; border-top: 1px solid rgba(229,223,207,.12); text-align: center; font: 7px/1.5 monospace; text-transform: uppercase; letter-spacing: .15em; color: rgba(230,223,205,.34); }

.contact-overlay { position: fixed; inset: 0; z-index: 320; display: grid; place-items: center; padding: 22px; background: rgba(7,9,7,.88); backdrop-filter: blur(14px); animation: theaterIn .25s ease both; }
.contact-modal { position: relative; isolation: isolate; overflow: hidden; width: min(680px,100%); padding: clamp(38px,5vw,68px); border: 0; border-radius: clamp(20px,2.5vw,34px); background: linear-gradient(145deg,#39483d,#253029); color: var(--paper); box-shadow: 0 45px 110px rgba(0,0,0,.48); }
.contact-modal h2 { max-width: 560px; margin: 18px 0 26px; font: 400 clamp(54px,7vw,88px)/.88 "Iowan Old Style","Baskerville",serif; letter-spacing: -.05em; }
.contact-modal > p:not(.eyebrow) { max-width: 520px; margin: 0; color: rgba(229,223,207,.7); line-height: 1.6; }
.contact-email { display: flex; align-items: center; justify-content: space-between; gap: 20px; margin-top: 40px; padding: 18px 20px; border-radius: 11px; background: var(--amber); color: var(--ink); font-size: 12px; font-weight: 700; letter-spacing: .04em; overflow-wrap: anywhere; }
.contact-email svg { flex: 0 0 auto; width: 19px; }

.privacy-overlay { position: fixed; inset: 0; z-index: 360; display: grid; place-items: center; padding: 22px; background: rgba(7,9,7,.88); backdrop-filter: blur(12px); animation: theaterIn .25s ease both; }
.privacy-modal { position: relative; isolation: isolate; width: min(760px,100%); max-height: calc(100svh - 44px); overflow-x: hidden; overflow-y: auto; padding: clamp(28px,5vw,62px); border: 0; border-radius: clamp(18px,2.2vw,30px); background: #273129; color: var(--paper); box-shadow: 0 45px 110px rgba(0,0,0,.45); }
.privacy-topline { display: flex; align-items: center; justify-content: space-between; gap: 30px; }
.privacy-topline button { display: flex; align-items: center; gap: 8px; padding: 0; border: 0; background: transparent; color: inherit; text-transform: uppercase; letter-spacing: .15em; font-size: 9px; line-height: 1; cursor: pointer; }
.privacy-topline button span { width: 20px; height: 20px; display: grid; place-items: center; font-size: 20px; font-weight: 200; line-height: 1; transform: translateY(-1px); }
.privacy-modal h2 { margin: 42px 0 30px; font-size: clamp(48px,7vw,83px); line-height: .88; }
.privacy-modal > p { margin: 0 0 20px; max-width: 660px; font-size: 15px; line-height: 1.65; color: rgba(229,223,207,.76); }
.privacy-modal .privacy-updated { margin: 35px 0 0; padding-top: 20px; border-top: 1px solid rgba(229,223,207,.17); text-transform: uppercase; font: 8px monospace; letter-spacing: .13em; color: rgba(229,223,207,.43); }

/* .wall-switch positioning. It sits in the open brick margin above the
   footer, not inside the footer's own band — `bottom: 100%` (relative to
   <footer>, its containing block) puts its own bottom edge exactly at the
   footer's top edge regardless of the footer's actual rendered height,
   then margin-bottom pushes it further up into that open margin; no JS
   measurement involved, so nothing can drift the way two earlier
   attempts did. No visible fill either — this is a pure hotspot,
   background stays transparent, no border-radius, nothing to render.
   Sized generously (60x60) since
   there's no visual shape it needs to match anymore, and a bigger hidden
   hit target is easier to stumble onto/tap accurately without ever being
   visible. Still lives in <footer> in the DOM purely for the stable
   position:relative anchor — semantically inert either way, since
   aria-hidden + tabindex="-1" (library/index.html) already remove it from
   the accessibility tree regardless of where it sits in the markup.
   cursor:pointer is the only affordance a sighted user gets; touch
   devices get the same tap-to-open behavior for free from the underlying
   <button>. */
/* Left edge, not right: the shelf's sound toggle sits at the right, and
   an invisible 60x60 hit target next to it meant people reaching for
   sound kept opening the codeword prompt by accident. Same vertical
   position, mirrored horizontally, so it stays equally stumble-onto-able
   without sharing an edge with a real control. */
.wall-switch { position: absolute; z-index: 1; bottom: 100%; margin-bottom: 60px; left: clamp(40px,8vw,120px); width: 60px; height: 60px; padding: 0; border: 0; background: transparent; cursor: pointer; }

.codeword-modal-overlay {
  position: fixed;
  inset: 0;
  /* Round three fix: inset:0 alone sizes a fixed element to the layout
     viewport, which on mobile Chrome/Safari includes the space behind
     the collapsible URL bar even while it's visibly showing — so
     place-items:center was centering against a box taller than what's
     actually on screen, pushing the modal up into the top half. 100svh
     is always the *smallest* possible viewport (URL bar fully expanded),
     which matches what's really visible regardless of scroll/toolbar
     state, so this stays genuinely centered. */
  height: 100svh;
  z-index: 350;
  display: grid;
  place-items: center;
  padding: 22px;
  /* No flat rgba fill here on purpose — that read as its own distinct
     "background bit" boxed in behind the modal rather than the real page
     just going dark. backdrop-filter alone (blur + a brightness cut)
     dims and softens whatever's actually behind instead of stacking a
     separate tinted rectangle on top of it. */
  backdrop-filter: blur(12px) brightness(.4);
  animation: theaterIn .25s ease both;
}
/* .codeword-modal — round three. Round two's dark textured "backer slab"
   sitting behind a separate lighter panel was one layer too many; back
   to a single stone surface resting straight on the overlay's own
   darkened backdrop — no border, no second background behind it. Edge
   definition comes entirely from one light and one dark inset shadow
   (bright top-left, dark bottom-right), like a single block lit from one
   side, instead of an actual border line. Title/copy get a light-above,
   dark-below text-shadow pair so the words read as pressed into the
   stone rather than printed on it. No .popup-close here on purpose —
   closing is backdrop-click or Escape only (see wireCodewordLever in
   render-library.js), so there's nothing to click but the stone itself. */
.codeword-modal {
  position: relative;
  isolation: isolate;
  width: min(420px,100%);
  padding: clamp(28px,5vw,44px);
  border: 0;
  border-radius: clamp(10px,1.6vw,16px);
  background:
    radial-gradient(140% 90% at 15% 0%, rgba(255,255,255,.05), transparent 55%),
    linear-gradient(160deg,#5c5c56 0%,#46463f 48%,#333330 100%);
  box-shadow:
    inset 4px 4px 0 rgba(255,255,255,.12),
    inset -4px -4px 0 rgba(0,0,0,.6),
    inset 0 0 40px rgba(0,0,0,.2),
    0 40px 100px rgba(0,0,0,.55);
  animation: bookOpen .5s cubic-bezier(.2,.74,.18,1) both;
}
/* "Stronghold Print" is the same blocky, book-chrome font already used
   for decoded book interiors (see the @font-face block and .lore-
   paragraph--plain/.glyph-word.decoded below) — reused here rather than
   Archive Serif so the codeword prompt reads as part of the same found-
   object world as the books. */
.codeword-modal #codeword-title { margin: 0 0 8px; font-family: "Stronghold Print","Menlo","Consolas",monospace; letter-spacing: .07em; color: #cac1a4; text-shadow: 0 1px 0 rgba(255,255,255,.12), 0 -1px 1px rgba(0,0,0,.55); }
.codeword-modal-copy { margin: 0 0 22px; font-family: "Stronghold Print","Menlo","Consolas",monospace; font-size: 13px; line-height: 1.55; color: rgba(202,193,164,.66); text-shadow: 0 1px 0 rgba(255,255,255,.1), 0 -1px 1px rgba(0,0,0,.5); }
#codeword-form { display: flex; gap: 10px; }
#codeword-input { flex: 1 1 auto; min-width: 0; padding: 11px 14px; border: 0; border-radius: 6px; background: rgba(0,0,0,.32); color: #ece4c9; font-family: "Stronghold Print","Menlo","Consolas",monospace; font-size: 14px; letter-spacing: .02em; box-shadow: inset 0 2px 5px rgba(0,0,0,.5), inset 0 0 0 1px rgba(0,0,0,.3); }
#codeword-input::placeholder { color: rgba(236,228,201,.35); }
#codeword-input:focus-visible { outline: 2px solid rgba(202,193,164,.5); outline-offset: 2px; }
#codeword-form button[type="submit"] { flex: 0 0 auto; padding: 11px 18px; border: 0; border-radius: 6px; background: linear-gradient(160deg,#4c4c47 0%,#2b2b28 100%); color: #d8d0b8; font-family: "Stronghold Print","Helvetica Neue",Helvetica,Arial,sans-serif; font-weight: 700; font-size: 12px; text-transform: uppercase; letter-spacing: .08em; cursor: pointer; box-shadow: inset 1px 1px 0 rgba(255,255,255,.12), inset -1px -1px 0 rgba(0,0,0,.5); transition: filter .2s ease; }
#codeword-form button[type="submit"]:hover { filter: brightness(1.15); }
/* Wrong-guess feedback — a quick horizontal shake, no text, no hint.
   wireCodewordLever (render-library.js) toggles this class on and off the
   input around a failed /api/unlock response. */
@keyframes codewordShake { 20%,60% { transform: translateX(-6px); } 40%,80% { transform: translateX(6px); } }
#codeword-input.is-wrong { animation: codewordShake .4s ease; }
/* The one deliberate exception to this modal's otherwise-uninformative
   failure state — see the [hidden] toggle in wireCodewordLever
   (render-library.js) and its own comment in library/index.html. Reuses
   .content-error's warm-red (styles.css) rather than inventing a second
   error color. */
.codeword-modal-message { margin: 12px 0 0; font-family: "Stronghold Print","Menlo","Consolas",monospace; font-size: 12px; line-height: 1.5; color: #ffd8cf; text-shadow: 0 1px 0 rgba(255,255,255,.1), 0 -1px 1px rgba(0,0,0,.5); }

.theater { position: fixed; inset: 0; z-index: 200; background: rgba(5,5,4,.97); display: grid; place-items: center; padding: 70px clamp(15px,4vw,60px) 45px; animation: theaterIn .38s ease both; }
.theater > *:not(.modal-grain) { z-index: 2; }
@keyframes theaterIn { from { opacity: 0; } }
.theater-topline { position: absolute; top: 22px; left: clamp(15px,4vw,60px); right: clamp(15px,4vw,60px); display: flex; justify-content: space-between; align-items: center; font: 9px monospace; text-transform: uppercase; letter-spacing: .18em; color: rgba(230,223,205,.65); }
.theater-topline button { background: none; border: 0; color: var(--paper); cursor: pointer; text-transform: uppercase; letter-spacing: .16em; font-size: 9px; display: flex; align-items: center; gap: 9px; }
.theater-topline button span { font-size: 22px; font-weight: 200; }
.theater-screen { width: min(91vw,1370px); aspect-ratio: 16/9; position: relative; background: #000; box-shadow: 0 0 80px rgba(190,160,95,.12); border-radius: 7px; overflow: hidden; animation: screenSettle 2.25s cubic-bezier(.2,.72,.2,1) both; }
.theater-screen iframe { width: 100%; height: 100%; border: 0; display: block; }
.theater-screen video { width: 100%; height: 100%; display: block; object-fit: contain; background: #000; }
.theater-screen:fullscreen { width: 100%; height: 100%; max-width: none; aspect-ratio: auto; border-radius: 0; }

/* Custom clip-player bar (openVideoClip/wireClipControls, app.js) —
   replaces the browser's own <video controls>, which shows itself the
   instant the element exists rather than only on hover. Hidden by
   default; JS toggles .is-visible on hover/focus and auto-hides again
   after a couple seconds of playback with no interaction, same rhythm as
   any native player, just built from the site's own vocabulary (amber,
   Stronghold-adjacent monospace, no rounded "app" chrome) instead of the
   OS/browser's. */
.clip-controls { position: absolute; left: 0; right: 0; bottom: 0; z-index: 2; display: flex; align-items: center; gap: 12px; padding: 10px 16px 12px; background: linear-gradient(180deg,transparent,rgba(0,0,0,.82) 65%); opacity: 0; pointer-events: none; transition: opacity .25s ease; }
.clip-controls.is-visible { opacity: 1; pointer-events: auto; }
.clip-control-btn { flex: 0 0 auto; border: 0; background: none; padding: 4px; display: grid; place-items: center; color: var(--paper); cursor: pointer; transition: color .2s ease; }
.clip-control-btn:hover, .clip-control-btn:focus-visible { color: var(--amber); }
.clip-time { flex: 0 0 auto; min-width: 30px; text-align: center; font: 9px monospace; letter-spacing: .06em; color: rgba(230,223,205,.75); }
.clip-scrubber { --clip-fill: 0%; flex: 1; -webkit-appearance: none; appearance: none; height: 3px; border-radius: 2px; cursor: pointer; background: linear-gradient(to right,var(--amber) 0 var(--clip-fill),rgba(230,223,205,.28) var(--clip-fill) 100%); }
.clip-scrubber::-webkit-slider-thumb { -webkit-appearance: none; width: 11px; height: 11px; margin-top: -4px; border-radius: 50%; background: var(--amber); box-shadow: 0 0 0 3px rgba(185,163,101,.22); }
.clip-scrubber::-moz-range-thumb { width: 11px; height: 11px; border: 0; border-radius: 50%; background: var(--amber); box-shadow: 0 0 0 3px rgba(185,163,101,.22); }
.clip-scrubber::-moz-range-track { height: 3px; border-radius: 2px; background: transparent; }
.projector-flicker { position: absolute; inset: 0; pointer-events: none; overflow: hidden; background: #050403; animation: projectorCurtain 2.25s cubic-bezier(.22,.7,.25,1) forwards; }
.projector-flicker::before { content: ""; position: absolute; left: 50%; top: 50%; width: 95%; aspect-ratio: 1; border-radius: 50%; transform: translate(-50%,-50%) scale(.03); opacity: 0; background: radial-gradient(circle,rgba(255,225,163,.62) 0%,rgba(218,184,119,.24) 24%,rgba(67,55,36,.08) 56%,transparent 72%); mix-blend-mode: screen; animation: lampBloom 1.85s .16s cubic-bezier(.18,.65,.24,1) forwards; }
.projector-flicker::after { content: ""; position: absolute; top: -15%; bottom: -15%; left: 47%; width: 1px; opacity: 0; background: linear-gradient(180deg,transparent,rgba(255,244,216,.38) 26% 72%,transparent); box-shadow: 18px 0 0 rgba(255,244,216,.08); animation: leaderScratch 1.15s .52s ease-out forwards; }
@keyframes projectorCurtain { 0%,12% { opacity: 1; } 42% { opacity: .76; } 67% { opacity: .28; } 84% { opacity: .09; } 100% { opacity: 0; } }
@keyframes lampBloom { 0% { opacity: 0; transform: translate(-50%,-50%) scale(.03); } 24% { opacity: .48; } 62% { opacity: .3; } 100% { opacity: 0; transform: translate(-50%,-50%) scale(1.85); } }
@keyframes leaderScratch { 0% { opacity: 0; transform: translateX(-38px); } 28% { opacity: .22; } 100% { opacity: 0; transform: translateX(52px); } }
@keyframes screenSettle { 0% { transform: scale(.992) translateY(1px); filter: sepia(.18) brightness(.72) contrast(.92); } 52% { transform: scale(1.002); filter: sepia(.09) brightness(.94) contrast(.98); } 100% { transform: scale(1); filter: none; } }
.projector-beam { position: absolute; left: 50%; top: -15%; width: 76vw; height: 90%; transform: translateX(-50%); background: conic-gradient(from 166deg at 50% 0%,transparent 0 14deg,rgba(214,194,145,.035) 16deg 28deg,transparent 30deg); filter: blur(15px); pointer-events: none; }
.theater-note { position: absolute; bottom: 16px; margin: 0; font: 8px monospace; text-transform: uppercase; letter-spacing: .18em; color: rgba(230,223,205,.38); }

/* Glyph-swirl intro — separate from .projector-flicker above, which
   stays exactly as-is for the normal project-reel openProjection() path.
   This only plays for openVideoClip(), built in app.js's
   buildClipGlyphIntro(). Runs about 4.2s to roughly match
   unlock-correct-audio's length: a ~0.85s broken-signal
   glitch, then a ring of letters set in the same "Keeper Script" font
   used for undecoded book runes elsewhere spiral inward and dissolve,
   like pages/runes being drawn into an enchanting table, before the
   whole overlay fades to reveal the clip (which has been autoplaying
   underneath the entire time — this is decorative only, never blocks or
   delays playback). */
.clip-glyph-intro { position: absolute; inset: 0; z-index: 3; display: grid; place-items: center; overflow: hidden; pointer-events: none; background: #000; animation: clipIntroDissolve 4.2s ease both; }
@keyframes clipIntroDissolve { 0%, 90% { opacity: 1; } 100% { opacity: 0; } }

.clip-glyph-glitch { position: absolute; inset: 0; background: repeating-linear-gradient(0deg, rgba(255,255,255,.05) 0 1px, transparent 1px 3px), #050403; animation: clipGlyphGlitch .85s steps(11) both; }
@keyframes clipGlyphGlitch {
  0% { opacity: 1; transform: translate(0,0); clip-path: inset(0 0 0 0); }
  8% { transform: translate(-2%,0); clip-path: inset(10% 0 60% 0); }
  16% { transform: translate(3%,0); clip-path: inset(50% 0 10% 0); }
  24% { transform: translate(-1%,0); clip-path: inset(20% 0 40% 0); }
  32% { transform: translate(2%,0); clip-path: inset(70% 0 5% 0); }
  40% { transform: translate(-3%,0); clip-path: inset(5% 0 70% 0); }
  55% { transform: translate(1%,0); clip-path: inset(35% 0 35% 0); opacity: 1; }
  70% { opacity: .55; }
  100% { opacity: 0; transform: translate(0,0); clip-path: inset(0 0 0 0); }
}

/* Each letter is one span, positioned on a ring via --angle/--radius (set
   inline by buildClipGlyphIntro()) and spiraled inward via the rotate-
   translate-rotate trick (rotate to the ring position, push out by
   --radius, rotate back so the glyph itself stays upright) while --angle
   itself keeps increasing through the animation for the swirl. --delay
   staggers each glyph so they don't all move in lockstep. */
.clip-glyph {
  position: absolute;
  top: 50%;
  left: 50%;
  font-family: "Keeper Script","Menlo","Consolas",monospace;
  font-size: clamp(20px,3.2vw,38px);
  color: rgba(230,223,205,.85);
  text-shadow: 0 0 16px rgba(230,223,205,.55);
  opacity: 0;
  transform: translate(-50%,-50%) rotate(var(--angle)) translateY(var(--radius)) rotate(calc(var(--angle) * -1)) scale(.4);
  animation: clipGlyphSwirl 3s cubic-bezier(.2,.7,.15,1) both;
  animation-delay: var(--delay);
}
@keyframes clipGlyphSwirl {
  0% { opacity: 0; transform: translate(-50%,-50%) rotate(var(--angle)) translateY(var(--radius)) rotate(calc(var(--angle) * -1)) scale(.4); }
  14% { opacity: 1; }
  78% { opacity: 1; transform: translate(-50%,-50%) rotate(calc(var(--angle) + 300deg)) translateY(6px) rotate(calc((var(--angle) + 300deg) * -1)) scale(1.05); }
  100% { opacity: 0; transform: translate(-50%,-50%) rotate(calc(var(--angle) + 340deg)) translateY(0) rotate(calc((var(--angle) + 340deg) * -1)) scale(.2); }
}

.entry-popup { position: fixed; inset: 0; z-index: 300; display: grid; place-items: center; padding: 22px; background: rgba(7,9,7,.82); backdrop-filter: blur(14px); animation: popupBackdropIn .42s ease both; }
.entry-popup.closing { animation: popupBackdropOut .36s ease both; pointer-events: none; }
.entry-popup-card { position: relative; width: min(620px,100%); padding: clamp(34px,5vw,68px); border: 0; border-radius: clamp(20px,2.5vw,34px); overflow: hidden; background: linear-gradient(145deg,#475746,#303d33); box-shadow: 0 45px 110px rgba(0,0,0,.46); animation: popupCardIn .54s cubic-bezier(.2,.75,.2,1) both; }
.entry-popup.closing .entry-popup-card { animation: popupCardOut .36s ease both; }
.entry-popup-card::after { content: ""; position: absolute; z-index: 1; inset: 0; pointer-events: none; background: radial-gradient(circle at 88% 8%,rgba(255,240,194,.1),transparent 32%); }
.entry-popup-card > *:not(.modal-grain):not(.popup-close), .privacy-modal > *:not(.modal-grain), .contact-modal > *:not(.modal-grain):not(.popup-close) { position: relative; z-index: 2; }
.modal-grain { position: absolute; z-index: 1; inset: -18%; pointer-events: none; opacity: .22; background: url('/assets/film-grain.webp') center / 720px 405px repeat; mix-blend-mode: overlay; filter: contrast(245%) brightness(105%); animation: modalGrain .8s steps(2) infinite; will-change: transform; }
@keyframes modalGrain { 0%,100% { transform: translate3d(-1.2%,1%,0); } 33% { transform: translate3d(1.1%,-.8%,0); } 66% { transform: translate3d(.7%,1.3%,0); } }
.popup-close { position: absolute; z-index: 2; top: 19px; right: 22px; width: 38px; height: 38px; display: grid; place-items: center; padding: 0; border: 0; border-radius: 50%; background: rgba(229,223,207,.08); color: var(--paper); font-size: 24px; font-weight: 200; cursor: pointer; transition: background .2s ease,transform .2s ease; }
.popup-close:hover { background: rgba(229,223,207,.15); transform: rotate(4deg); }
.popup-title { display: block; max-width: 520px; margin: 18px 0 22px; font-size: clamp(47px,6vw,78px); line-height: .9; }
.popup-copy { max-width: 520px; margin: 0; color: rgba(241,237,226,.76); font-size: 16px; line-height: 1.55; }
.popup-form { margin-top: 40px; }
.popup-form .field-block { margin-bottom: 25px; }
.popup-form-meta { display: flex; align-items: flex-start; justify-content: space-between; gap: 24px; }
.popup-form-meta .privacy-link { flex: 0 0 auto; margin-top: 12px; }
.popup-confirmation { min-height: 320px; display: flex; flex-direction: column; justify-content: center; }
@keyframes popupBackdropIn { from { opacity: 0; } }
@keyframes popupBackdropOut { to { opacity: 0; } }
@keyframes popupCardIn { from { opacity: 0; transform: translateY(22px) scale(.985); } }
@keyframes popupCardOut { to { opacity: 0; transform: translateY(12px) scale(.99); } }

.subpage-shell { min-height: 100svh; background: linear-gradient(180deg,#161914 0%,#20271f 48%,#2d392f 100%); }
/* Library-only override — the rest of the site keeps the standard green
   subpage gradient above; only this page swaps to a dark charcoal, stone-
   brick backdrop, per the "dungeon library" direction: darker than the
   rest of the site, and stone-textured rather than plain, so the shelf
   itself (and the amber lantern light on it) reads as the one warm,
   eye-catching thing in the room.
   Round 30's first attempt (two perpendicular repeating-linear-gradients)
   only produced a plain grid, not a real brick look — no offset between
   rows, no per-brick tonal variation. Replaced (round 31) with an actual
   running-bond brick pattern: a small inline SVG tile (5 rects — 2 full
   bricks in row one, plus a left-partial/middle/right-partial in row two,
   offset by half a brick width) as a `background-image` data URI, tiled
   via `background-repeat` — no external image request, no build step,
   still just CSS/markup. The tile is self-tiling by design: the row-two
   brick that gets clipped at one edge of a tile is completed by the
   matching partial brick at the *other* edge of the next tile over, so
   adjacent copies line up into continuous bricks with no visible seam.
   Neutral charcoal-gray palette (not the site's usual warm browns) to
   match the stone reference image directly; two broad, very low-opacity
   radial gradients on top add a little depth without breaking the tiling
   pattern itself (they're their own background layer, not baked into the
   tile). All static — no animation, no ongoing repaint cost beyond a
   normal tiled background. */
.subpage-shell.library-page {
  position: relative;
  isolation: isolate;
  /* Round 33: bricks scaled up (100x50 -> 168x84, same 64x32 tile just
     displayed bigger — vector background, so this stays crisp, not
     blurry) and the whole palette pushed further toward near-black per
     direct feedback that the previous charcoal still wasn't dark enough. */
  background:
    radial-gradient(60% 45% at 18% 20%, rgba(255,255,255,.03), transparent 60%),
    radial-gradient(55% 50% at 78% 70%, rgba(0,0,0,.3), transparent 65%),
    url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2264%22%20height%3D%2232%22%20viewBox%3D%220%200%2064%2032%22%3E%3Crect%20width%3D%2264%22%20height%3D%2232%22%20fill%3D%22%230a0908%22%2F%3E%3Crect%20x%3D%221%22%20y%3D%221%22%20width%3D%2230%22%20height%3D%2214%22%20fill%3D%22%23161311%22%2F%3E%3Crect%20x%3D%2233%22%20y%3D%221%22%20width%3D%2230%22%20height%3D%2214%22%20fill%3D%22%23100e0c%22%2F%3E%3Crect%20x%3D%22-15%22%20y%3D%2217%22%20width%3D%2230%22%20height%3D%2214%22%20fill%3D%22%23100e0c%22%2F%3E%3Crect%20x%3D%2217%22%20y%3D%2217%22%20width%3D%2230%22%20height%3D%2214%22%20fill%3D%22%23181410%22%2F%3E%3Crect%20x%3D%2249%22%20y%3D%2217%22%20width%3D%2230%22%20height%3D%2214%22%20fill%3D%22%230d0b0a%22%2F%3E%3C%2Fsvg%3E");
  background-size: auto, auto, 168px 84px;
  background-repeat: no-repeat, no-repeat, repeat;
  background-color: #0a0908;
}
/* Strong page-wide vignette, separate from the shelf cabinet's own local
   vignette (.library-shelf-frame::after, unrelated/unchanged) — this one
   darkens the whole page's edges so the eye is pulled toward the center
   (the shelf) regardless of scroll position. z-index:-1 relies on
   .subpage-shell.library-page's own isolation:isolate (added above) to
   stay correctly behind the header/section/footer's normal in-flow
   content without an explicit z-index needed on each of those — same
   z-index:-1-behind-in-flow-content technique already used for the shelf
   ledge (see .library-shelf-ledge). */
.subpage-shell.library-page::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background: radial-gradient(120% 70% at 50% 35%, transparent 32%, rgba(0,0,0,.55) 72%, rgba(0,0,0,.88) 100%);
}
/* Round 52 — mobile browsers (Chrome/Safari on Android/iOS) paint a
   translucent highlight rectangle over any element that just received a
   tap, as a built-in "yes, that registered" affordance for links/buttons.
   It's meant for normal text links, so on custom, already-visibly-
   reactive controls like the candles, book spines, scrolls and glyph-
   word paragraph taps here it reads instead as a stray selection box.
   Scoped to this page only (not sitewide) since those controls are
   unique to it; * is broad on purpose — this only ever has a visible
   effect on elements that are actually tap targets, so it's harmless on
   everything else in between.
   Also covers .book-overlay/.completion-note/.sound-prompt explicitly —
   round 52's original .subpage-shell.library-page selector missed these
   entirely, since render-library.js appends all three straight to
   <body> (so the book modal's own popup-close/pager/side-nav buttons,
   and the completion note's and sound prompt's buttons, still showed
   the tap box) rather than nesting them inside .subpage-shell.library-page.
   All three classes only ever get created by this page's own script, so
   this stays just as page-scoped as the original rule was meant to be.
   .codeword-modal-overlay is included for the same reason: it's a
   sibling of <main>, not a descendant, so .subpage-shell.library-page's
   own selector never reaches it. */
.subpage-shell.library-page *,
.book-overlay *,
.completion-note *,
.sound-prompt *,
.codeword-modal-overlay * {
  -webkit-tap-highlight-color: transparent;
}
/* Disable text selection across the whole library page. This is a
   moody, image-forward page (shelf, book pages) rather than a page
   anyone needs to copy prose out of, and drag-selection was catching on
   hover/click targets in a way that read as a bug. Same selector set as
   the tap-highlight rule above, plus the root elements themselves (not
   just their descendants) so the containers can't be selected either.
   Form controls still behave normally for typing/selecting within the
   codeword input — user-select doesn't apply to a form control's own
   editable text regardless of this rule, so it stays fully usable. */
.subpage-shell.library-page,
.subpage-shell.library-page *,
.book-overlay,
.book-overlay *,
.completion-note,
.completion-note *,
.sound-prompt,
.sound-prompt *,
.codeword-modal-overlay,
.codeword-modal-overlay * {
  -webkit-user-select: none;
  user-select: none;
}
/* ...except the book itself, which is prose people need to be able to take
   out of the page. The rule above was written for the shelf, where drag-
   selection catches on hover targets and there is nothing worth copying —
   but it also covered every open book, which meant a reader could not
   select a passage to paste into a translator, and killed the long-press
   to Translate flow on mobile outright. This site has a large non-English
   audience, so that is a real barrier rather than a cosmetic one. Same
   specificity as the rule above and placed after it, so it wins on order.
   The shelf stays unselectable. */
.book-modal,
.book-modal * {
  -webkit-user-select: text;
  user-select: text;
}
.subpage-shell .site-header { position: relative; background: rgba(14,17,13,.45); backdrop-filter: blur(13px); }
.page-main { min-height: 72svh; padding: clamp(48px,7vw,105px) clamp(20px,6vw,92px) clamp(80px,10vw,150px); }
.page-heading { display: grid; grid-template-columns: 1.45fr .55fr; align-items: end; gap: 8vw; margin: clamp(55px,8vw,118px) 0 clamp(45px,6vw,80px); }
.page-heading h1 { margin: 8px 0 0; font-size: clamp(68px,11vw,170px); line-height: .8; text-transform: uppercase; }
#bts-title { font-size: clamp(58px,9vw,138px); }
.episode-list { display: grid; gap: 16px; margin: 0; padding: 0; list-style: none; }
.episode-card { position: relative; }
.episode-card > button, .bts-card-inner { position: relative; isolation: isolate; display: grid; grid-template-columns: minmax(180px,320px) 72px minmax(0,1fr) auto; align-items: center; gap: clamp(18px,3vw,46px); width: 100%; padding: 18px; border: 0; border-radius: 20px; overflow: hidden; background: rgba(105,119,100,.52); color: var(--paper); text-align: left; box-shadow: 0 12px 28px rgba(0,0,0,.16); transition: transform .34s cubic-bezier(.2,.7,.2,1),background .34s ease,box-shadow .34s ease; }
.episode-card > button { cursor: pointer; }
.episode-card:nth-child(even) > button, .bts-card:nth-child(even) .bts-card-inner { background: rgba(77,93,79,.6); }
.episode-thumb { position: relative; display: block; aspect-ratio: 16/9; overflow: hidden; border-radius: 12px; background: radial-gradient(circle at 50% 45%,rgba(185,163,101,.12),transparent 32%),linear-gradient(145deg,#172019,#080a08); }
.episode-thumb img { width: 100%; height: 100%; display: block; object-fit: cover; filter: saturate(.72) brightness(.82); transition: transform .7s ease,filter .4s ease; }
.episode-play { position: absolute; inset: 50% auto auto 50%; width: 52px; height: 52px; display: grid; place-items: center; border-radius: 42%; background: var(--amber); color: var(--ink); transform: translate(-50%,-50%); box-shadow: 0 10px 26px rgba(0,0,0,.25); }
.episode-play svg { width: 50%; }
.episode-play { font-size: 20px; }
.ep-num, .ep-watch { text-transform: uppercase; letter-spacing: .16em; font: 9px monospace; color: rgba(229,223,207,.58); }
.ep-body { display: flex; min-width: 0; flex-direction: column; gap: 10px; }
.ep-title { font: 400 clamp(28px,3.4vw,52px)/.95 "Iowan Old Style","Baskerville",serif; letter-spacing: -.035em; }
.ep-desc { max-width: 650px; color: rgba(229,223,207,.67); font-size: 14px; line-height: 1.5; }
.ep-watch { justify-self: end; white-space: nowrap; color: var(--amber); }
.episode-bts-link { display: flex; width: max-content; align-items: center; gap: 9px; margin: 10px 16px 2px auto; color: rgba(229,223,207,.62); text-transform: uppercase; letter-spacing: .14em; font: 8px monospace; transition: color .2s ease; }
.episode-bts-link:hover { color: var(--amber); }
.content-status { padding: 46px 22px; border-radius: 20px; background: rgba(77,93,79,.35); color: rgba(229,223,207,.6); text-align: center; text-transform: uppercase; letter-spacing: .15em; font: 9px monospace; }
.content-error { color: #ffd8cf; }
.bts-card-inner { min-height: 230px; }
.bts-card.is-locked .bts-card-inner { background: linear-gradient(145deg,rgba(51,61,51,.94),rgba(31,38,32,.96)); }
.bts-card.is-locked .episode-thumb img { filter: grayscale(.55) saturate(.42) brightness(.5); }
.bts-thumb { isolation: isolate; }
.bts-thumb::after { content: ""; position: absolute; inset: 0; z-index: 1; background: linear-gradient(180deg,rgba(8,10,8,.08),rgba(8,10,8,.55)); pointer-events: none; }
.bts-lock { position: absolute; z-index: 2; inset: 50% auto auto 50%; display: grid; place-items: center; gap: 5px; transform: translate(-50%,-50%); color: #f2e8ce; text-align: center; font-size: 34px; }
.bts-lock strong { font: 8px monospace; text-transform: uppercase; letter-spacing: .18em; }
.bts-card.is-free .bts-card-inner { cursor: pointer; }
.locked-teaser { color: rgba(229,223,207,.52); }
.bts-actions { display: flex; min-width: 150px; flex-direction: column; align-items: stretch; gap: 10px; }
.bts-primary-link, .bts-back-link { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 13px 14px; border-radius: 9px; text-transform: uppercase; letter-spacing: .12em; font: 8px monospace; white-space: nowrap; }
.bts-primary-link { background: var(--amber); color: var(--ink); font-weight: 700; }
.bts-back-link { background: rgba(229,223,207,.08); color: rgba(229,223,207,.7); }

.thanks-page { display: flex; flex-direction: column; justify-content: center; }
.thanks-heading { max-width: 980px; margin: clamp(55px,8vw,105px) 0 clamp(44px,6vw,76px); }
.thanks-heading h1 { margin: 14px 0 26px; font-size: clamp(82px,13vw,188px); line-height: .75; text-transform: uppercase; }
.thanks-heading > p:last-child { max-width: 650px; margin: 0; color: rgba(229,223,207,.72); font: 400 clamp(19px,2vw,28px)/1.35 "Iowan Old Style","Baskerville",serif; }
.download-card { position: relative; isolation: isolate; overflow: hidden; display: grid; grid-template-columns: 1.35fr .65fr; align-items: end; gap: 7vw; padding: clamp(32px,5vw,72px); border: 0; border-radius: clamp(20px,2.5vw,34px); background: #4a594b; box-shadow: 0 18px 40px rgba(0,0,0,.2); transition: transform .38s cubic-bezier(.2,.7,.2,1),box-shadow .38s ease; }
.download-card > * { position: relative; z-index: 2; }
.download-title { display: block; margin: 18px 0 24px; font-size: clamp(54px,7vw,104px); line-height: .85; }
.download-copy > p:last-child { max-width: 500px; margin: 0; color: rgba(229,223,207,.7); line-height: 1.55; }
.download-button { width: 100%; min-height: 62px; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; border: 0; border-radius: 11px; background: var(--amber); color: var(--ink); text-transform: uppercase; letter-spacing: .13em; font-size: 10px; font-weight: 700; }
.download-button { cursor: pointer; }
.download-button span { font-size: 20px; }
.download-placeholder-note { margin: 13px 0 0; color: rgba(229,223,207,.5); font-size: 10px; line-height: 1.5; }

.signal-page { min-height: 100svh; position: relative; overflow: hidden; background: radial-gradient(circle at 50% 42%,#2d392f 0%,#1b211a 42%,#0d0f0c 100%); }
.signal-page::before { content: ""; position: absolute; inset: 0; pointer-events: none; opacity: .22; background: repeating-linear-gradient(180deg,transparent 0 3px,rgba(235,230,213,.06) 4px); animation: signalRoll 7s linear infinite; }
.signal-content { position: relative; z-index: 2; min-height: 100svh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 130px 22px 70px; text-align: center; }
.signal-code { position: absolute; z-index: 0; top: 50%; left: 50%; transform: translate(-50%,-50%); color: rgba(229,223,207,.025); font: 400 min(47vw,680px)/1 "Iowan Old Style","Baskerville",serif; pointer-events: none; user-select: none; }
.signal-content > :not(.signal-code) { position: relative; z-index: 1; }
.signal-content h1 { margin: 15px 0 24px; font: 400 clamp(78px,15vw,210px)/.75 "Iowan Old Style","Baskerville",serif; letter-spacing: -.06em; text-transform: uppercase; text-shadow: 3px 0 rgba(185,163,101,.16),-3px 0 rgba(133,157,140,.14); }
.signal-content > p:not(.eyebrow) { max-width: 520px; margin: 0; color: rgba(229,223,207,.66); font: 400 clamp(17px,2vw,25px)/1.4 "Iowan Old Style","Baskerville",serif; }
.signal-link { display: inline-flex; align-items: center; gap: 16px; margin-top: 38px; padding: 14px 18px; border-radius: 10px; background: var(--amber); color: var(--ink); text-transform: uppercase; letter-spacing: .14em; font-size: 9px; font-weight: 700; }
.signal-link svg { width: 18px; }
@keyframes signalRoll { from { transform: translateY(-16px); } to { transform: translateY(16px); } }

@media (hover: hover) and (pointer: fine) {
  .glow-card:hover { transform: translateY(-3px); box-shadow: 0 28px 52px rgba(8,12,9,.15); }
  .glow-card:hover::before { opacity: .72; }
  .episode-card > button:hover, .bts-card-inner:hover { transform: translateY(-3px); background: rgba(123,137,114,.64); box-shadow: 0 27px 48px rgba(0,0,0,.12); }
  .episode-card > button:hover .episode-thumb img, .bts-card-inner:hover .episode-thumb img { transform: scale(1.025); filter: saturate(.88) brightness(.92); }
  .bts-card.is-locked .bts-card-inner:hover .episode-thumb img { filter: grayscale(.4) saturate(.5) brightness(.58); }
}

@media (hover: none), (pointer: coarse) {
  .glow-card::before { display: none; }
}

.reveal { animation: reveal linear both; animation-timeline: view(); animation-range: entry 5% cover 26%; }
@keyframes reveal { from { opacity: 0; transform: translateY(38px); } to { opacity: 1; transform: none; } }

/* One-time "just appeared" entrance for a book/scroll that unlocks live
   while a reader already has the shelf open (pollForNewArrivals,
   render-library.js) — distinct from .reveal above, which is scroll-
   linked (animation-timeline: view()) and only actually plays as an item
   scrolls into view. A live arrival can appear while the shelf is
   already sitting still on screen, where a view-timeline animation would
   just snap straight to its settled end state with no visible motion at
   all — this is a plain time-based keyframe instead, so it always plays
   regardless of scroll position. render-library.js removes the class
   once it finishes (animationend), same reasoning as .reveal's own
   forced cleanup a few seconds after load: a fill-mode "both" animation
   otherwise leaves its end-state transform sitting on the element
   indefinitely, fighting the very next hover (.glow-card:hover above
   also animates transform). Left to animate opacity/transform only, not
   box-shadow — a just-arrived item also carries .is-new (newItemGlow/
   newItemGlowScroll above), which already owns that property. */
@keyframes shelfItemArrive { from { opacity: 0; transform: translateY(22px) scale(.94); } to { opacity: 1; transform: none; } }
.just-arrived { animation: shelfItemArrive .6s cubic-bezier(.2,.7,.3,1) both; }

@media (max-width: 820px) {
  .site-header { grid-template-columns: auto 1fr auto; grid-template-rows: auto auto; padding: 14px 16px 13px; gap: 9px 14px; }
  .brand-lockup span { display: none; }
  .brand-lockup { grid-column: 1; grid-row: 1; }
  .main-nav { grid-column: 1 / -1; grid-row: 2; justify-content: center; gap: clamp(24px,8vw,54px); width: 100%; }
  .main-nav > a, .nav-item > button { font-size: 10px; letter-spacing: .12em; }
  .header-cta { grid-column: 3; grid-row: 1; padding: 10px 12px; font-size: 9px; white-space: nowrap; }
  .header-cta svg { display: none; }
  .dropdown { left: 50%; transform: translateX(-50%) translateY(-6px); }
  .has-dropdown:hover .dropdown, .has-dropdown:focus-within .dropdown, .nav-item.open .dropdown { transform: translateX(-50%) translateY(0); }
  .hero { padding-left: 18px; padding-right: 18px; }
  .hero h1 { font-size: clamp(61px,20.4vw,120px); line-height: .76; }
  .hero h1 span:last-child { margin-left: 0; }
  .hero-eyebrow { gap: 8px; font-size: 8px; letter-spacing: .14em; }
  .hero-footer { align-items: center; }
  .hero-footer > p { max-width: 230px; }
  .frame-number { display: none; }
  .scroll-cue { gap: 10px; font-size: 8px; letter-spacing: .16em; }
  .scroll-line { width: 36px; }
  .film-heading, .manifesto-grid, .newsletter-card, .patreon-section, .community-section, .page-heading, .download-card { grid-template-columns: 1fr; }
  .film-heading { gap: 25px; }
  .film-logline { max-width: 520px; }
  .film-credit-line { flex-direction: column; gap: 7px; }
  .manifesto-grid { gap: 45px; }
  .manifesto-top { flex-direction: column; align-items: flex-start; gap: 12px; line-height: 1.55; }
  .manifesto-top span:last-child { max-width: 100%; text-align: left; }
  .process-strip { grid-template-columns: 1fr 1fr; }
  .newsletter-card { min-height: 620px; gap: 70px; }
  .input-row { grid-template-columns: 1fr; border: 0; gap: 12px; }
  .input-row input { border-bottom: 1px solid rgba(241,223,188,.75); }
  .input-row button { min-height: 52px; justify-content: space-between; border-radius: 9px; }
  .patreon-section { gap: 75px; }
  .patreon-credits { width: min(75vw,360px); align-self: auto; margin-top: 0; }
  .patreon-marquee { height: clamp(240px,55vw,340px); }
  .community-card { min-height: 540px; }
  footer { grid-template-columns: 1fr; align-items: start; }
  .social-links { flex-wrap: wrap; }
  .footer-meta { justify-self: start; text-align: left; }
  .footer-contact { grid-column: 1; }
  .page-heading { gap: 28px; }
  .episode-card > button, .bts-card-inner { grid-template-columns: minmax(145px,230px) 54px 1fr; }
  .ep-watch { display: none; }
  .bts-actions { grid-column: 3; min-width: 0; }
  .download-card { gap: 48px; }
}

@media (max-width: 500px) {
  .site-header { padding-left: 13px; padding-right: 13px; }
  .main-nav { gap: 19px; }
  .main-nav > a, .nav-item > button { font-size: 9px; }
  .header-cta { padding: 9px 10px; font-size: 8px; }
  .hero { min-height: 100svh; height: 100svh; }
  .hero h1 { font-size: 19vw; }
  .hero-footer > p { font-size: 17px; }
  .film-section, .manifesto, .newsletter { padding-left: 16px; padding-right: 16px; }
  .film-heading h2 { font-size: 14vw; }
  .film-poster { border-radius: 12px; }
  .poster-corners { inset: 8px; }
  .play-button { width: 62px; height: 62px; }
  .poster-caption { inset: auto 14px 13px; }
  .manifesto h2 { font-size: 13vw; }
  .process-strip span { padding: 15px 12px; font-size: 8px; }
  .newsletter-card { padding: 28px 22px; border-radius: 18px; }
  .newsletter h2, .patreon-copy h2 { font-size: 16vw; }
  .patreon-section { padding-left: 22px; padding-right: 22px; }
  .community-section { padding-left: 16px; padding-right: 16px; }
  .community-card { min-height: 520px; padding: 28px 22px; border-radius: 18px; }
  .community-card h2 { font-size: 15vw; }
  .community-actions { align-items: stretch; flex-direction: column; }
  .code-button { width: 100%; min-width: 0; }
  .community-link { justify-content: space-between; }
  .privacy-modal { padding: 28px 22px; }
  .privacy-topline button { gap: 6px; }
  .contact-modal { padding: 54px 22px 28px; }
  .contact-modal h2 { font-size: 13vw; }
  .contact-email { padding: 16px 14px; font-size: 10px; }
  .entry-popup { padding: 12px; }
  .entry-popup-card { padding: 58px 22px 28px; border-radius: 18px; }
  .popup-title { font-size: 12.5vw; }
  .page-main { padding-left: 16px; padding-right: 16px; }
  .page-heading h1 { font-size: 18vw; }
  .episode-card > button, .bts-card-inner { grid-template-columns: 1fr; padding: 12px; gap: 13px; }
  .episode-thumb { width: 100%; }
  .ep-num { margin-top: 4px; }
  .ep-title { font-size: 10vw; }
  .ep-desc { font-size: 13px; }
  #bts-title { font-size: 15vw; }
  .bts-actions { grid-column: auto; min-width: 0; }
  .thanks-page { padding-left: 22px; padding-right: 22px; }
  .thanks-heading { width: 100%; }
  .thanks-heading h1 { max-width: 100%; font-size: 17.5vw; }
  .download-card { padding: 28px 22px; }
  .theater { padding-left: 8px; padding-right: 8px; }
  .theater-screen { width: 100%; }
}

@media (max-width: 820px) and (orientation: portrait) {
  .hero { min-height: 100svh; height: 100svh; }
  .hero-image { background-image: url('/assets/hero-steve-pup-mobile.webp'); background-position: center center; }
  .hero-video { inset: -8%; width: 116%; height: 116%; object-position: 50% 50%; }
}

/* Replaces the original "SGA Handwritten" face (Standard Galactic Alphabet —
   the real Minecraft enchantment-table glyph style), which was never
   commercially licensable. Keeper Script is an original replacement, built to
   the same per-letter advance widths as Stronghold Print below wherever
   possible (see the letter-width comments throughout this file and
   render-library.js) so the encode/decode swap keeps the same tight
   width-matching the original face never had. */
@font-face {
  font-family: "Keeper Script";
  src: url("/fonts/keeper-script.ttf") format("truetype");
  font-display: swap;
  font-weight: 400;
  font-style: normal;
}

/* Used for legible book-interior text (titles, translated paragraphs, page
   chrome) once a passage has been decoded — distinct from "Keeper Script"
   above, which is only ever used for still-encoded runes. */
/* These four point at strongholdprintext-*.otf, an extended copy of the
   original strongholdprint-*.otf (still present alongside them, untouched,
   for rollback). The original covers ASCII but is missing most accented
   Latin characters and most typographic punctuation — no e-acute at all,
   43% of Portuguese, 30% of common punctuation — which meant any reader
   machine-translating the page got per-character fallback mid-word. The
   extended copy adds 89 glyphs per face, built on the font's own 100-unit
   pixel grid: the missing accented Latin, the missing typographic
   punctuation, and the full Russian Cyrillic alphabet.

   Accented capitals sit above cap height rather than compressing the base
   — a one-row mark on row 7, the two-row circumflex on rows 7-8, topping
   out at 900 against a 1000-unit ascender, so nothing ever reaches the
   ascender line and no accent can collide with the line above. The font's
   own N-tilde, A-dieresis and U-dieresis were redrawn to match, since
   mixing the old compressed convention with the new one would be obvious
   in German the moment A-dieresis sat beside O-dieresis.

   Cyrillic letters whose shape is identical to a Latin one (А В Е К М Н О
   Р С Т Х, а е о р с у х) are cmap aliases onto the existing glyph rather
   than copies, so they cannot drift apart from their Latin twins.

   LICENCE: the original is "Free for Commercial Use" by Jacob Debono, which
   grants use but does not clearly grant the right to ship a modified
   derivative. Confirm that before this goes to production. */
@font-face {
  font-family: "Stronghold Print";
  src: url("/fonts/strongholdprintext-regular.otf") format("opentype");
  font-display: swap;
  font-weight: 400;
  font-style: normal;
}
@font-face {
  font-family: "Stronghold Print";
  src: url("/fonts/strongholdprintext-bold.otf") format("opentype");
  font-display: swap;
  font-weight: 700;
  font-style: normal;
}
@font-face {
  font-family: "Stronghold Print";
  src: url("/fonts/strongholdprintext-italic.otf") format("opentype");
  font-display: swap;
  font-weight: 400;
  font-style: italic;
}
@font-face {
  font-family: "Stronghold Print";
  src: url("/fonts/strongholdprintext-bolditalic.otf") format("opentype");
  font-display: swap;
  font-weight: 700;
  font-style: italic;
}

/* Library — bookshelf + lore-decoding effect */
/* .library-shelf-frame is just a positioning/margin wrapper now — the
   wood cabinet look (background, rounded corners, clipping) lives on the
   nested .library-shelf-panel instead. They're split into two elements on
   purpose: overflow:hidden is needed on the rounded panel so dust motes,
   glows, and the vignette all get clipped to match its rounded corners
   (previously they were plain rectangles, so their sharp corners poked out
   past the wood panel's rounded corners) — but overflow:hidden on an
   element also clips that *same* element's own outer box-shadow, which
   would have killed the frame's outer drop shadow. Keeping the drop
   shadow on the (non-clipping) frame and the clipping on the (shadow-
   less) inner panel avoids that conflict. */
.library-shelf-frame { position: relative; margin: clamp(60px,7vw,100px) 0 clamp(30px,4vw,50px); border-radius: 16px; box-shadow: 0 30px 60px rgba(0,0,0,.55); }
/* Cabinet wood warmed up (round thirty-six) — the base gradient and its
   inset-border trim both had green sitting close to red (e.g. the old
   border rgba(102,72,38,...) is only ~30% darker in G than R), which
   reads as a brassy/mustard yellow rather than a stained wood brown.
   Pulled green down further relative to red on both, closer to a
   walnut/spruce-lumber tone. */
.library-shelf-panel { position: relative; isolation: isolate; overflow: hidden; padding: clamp(22px,3.2vw,36px) clamp(16px,2.4vw,28px) clamp(14px,2vw,22px); border-radius: inherit; background:
    repeating-linear-gradient(90deg, rgba(0,0,0,.14) 0px, transparent 2px, transparent 7px, rgba(255,255,255,.025) 8px, transparent 11px),
    linear-gradient(160deg,#261a10 0%,#1c130b 45%,#130d07 100%);
  box-shadow: inset 0 0 0 2px rgba(94,60,34,.55), inset 0 0 0 7px rgba(0,0,0,.4), inset 0 2px 0 rgba(255,255,255,.05);
}
/* Round 56 — a thin ring right at the cabinet's own inner edge (where the
   candle/cursor warm glow used to bleed all the way to it) now reads as
   solid, unlit trim instead — per the user's framing, "the walls of the
   bookcase" shouldn't react to the interior lighting. Repaints the exact
   same wood-grain background + carved-trim box-shadow as
   .library-shelf-panel itself, as a separate opaque layer stacked *above*
   both glow layers (shelf-cursor-glow at z-index 3, the candle's own
   ambient glow at z-index 0 — this sits at 4, unambiguously above both
   regardless of DOM order), then masks a hole out of the middle so only
   the ring itself is opaque. Round 56 follow-up: the ring first used
   `padding: inherit` to match the panel's own full padding (22-36px),
   masking out the entire content gutter — way more than intended, since
   the panel's real padding is *how far books/candles sit from the edge*,
   not the width of the decorative trim line itself. Switched to a fixed
   8px instead, just wide enough to fully cover the trim's own box-shadow
   (its widest inset stop is 7px) without eating into space the shelf
   items actually occupy. */
.library-shelf-panel::before {
  content: "";
  position: absolute;
  z-index: 4;
  inset: 0;
  padding: 8px;
  pointer-events: none;
  border-radius: inherit;
  background:
    repeating-linear-gradient(90deg, rgba(0,0,0,.14) 0px, transparent 2px, transparent 7px, rgba(255,255,255,.025) 8px, transparent 11px),
    linear-gradient(160deg,#261a10 0%,#1c130b 45%,#130d07 100%);
  box-shadow: inset 0 0 0 2px rgba(94,60,34,.55), inset 0 0 0 7px rgba(0,0,0,.4), inset 0 2px 0 rgba(255,255,255,.05);
  -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
  mask-composite: exclude;
}
/* Vignette — darkens the panel's edges so the shelf reads as a lit pocket
   in a darker room, rather than a flat rectangle. Transparent center keeps
   the books themselves unaffected; pointer-events:none so it never blocks
   clicks on spines poking near the edge. Lives on the panel (not the
   frame) so overflow:hidden there clips it to the same rounded corners. */
/* Strengthened (round thirty-seven) — the falloff now starts sooner
   (transparent center shrunk 38%→28%) and both outer stops are darker,
   so the corners read as noticeably shadowed/recessed rather than a
   subtle tint. A second, tighter radial-gradient layered underneath
   specifically targets the four corners a bit harder than the broad
   elliptical one above it manages on its own (an ellipse's falloff is
   naturally softer along its long axis than a plain corner-distance
   gradient would be), for more obvious depth without needing to darken
   the whole panel evenly. */
.library-shelf-panel::after { content: ""; position: absolute; z-index: 4; inset: 0; pointer-events: none; border-radius: inherit; background:
    radial-gradient(120% 100% at 50% 42%, transparent 28%, rgba(4,5,3,.62) 78%, rgba(2,3,2,.82) 100%),
    radial-gradient(75% 65% at 50% 50%, transparent 55%, rgba(0,0,0,.35) 100%);
}
/* A soft warm light that follows the cursor across the shelf, like reading
   by lamplight — position is updated in render-library.js via
   --shelf-glow-x/y, visibility toggled on pointer enter/leave so it doesn't
   linger in a stale spot. Sits below the spines (z-index 0) so it washes
   over the shelf itself without ever covering book labels. */
/* z-index 3 (above spines/scrolls/lamp at 1, below the vignette at 4) and
   mix-blend-mode: screen so this actually overlays the books and shelf
   like light falling on them, instead of sitting behind everything and
   only being visible in the gaps — that's how it looked before. */
/* Alpha lowered from .22 to .13 (round thirteen, per user feedback that
   mix-blend-mode:screen at the old intensity washed out the color of
   everything under the cursor a bit too much) — same warm tone and
   420px reach, just a softer wash. Applies on both desktop and mobile
   (wireShelfCursorGlow already only ever wires this up for mouse
   pointers in the first place, so it never actually runs on touch). */
/* Narrower radius + a steeper falloff than the original 420px/70% version
   (round 22) — reads more like a lantern's actual cone of light landing on
   the shelf than an even ambient wash, per feedback that the previous
   version still felt like a flat patch rather than something being held
   up and pointed at the books. animation only touches filter:brightness,
   never opacity — opacity stays fully owned by the is-active transition
   below, so the two don't fight over the same property. Ties the glow's
   brightness to shelfGlowFlicker, the same timing/curve as the candle's
   own lampGlowFlicker (just re-expressed as brightness instead of
   opacity, since opacity here is already spoken for) — the point is for
   the mouse-light and the candle to read as fed by one shared flame
   rather than two independent effects — the candle's own version of that
   timing now lives in drawBloomFrame's JS wobble math (render-library.js)
   rather than a CSS keyframe, since the ambient bloom moved to canvas,
   but it's still the same 3.1s curve. */
.shelf-cursor-glow { position: absolute; z-index: 3; inset: 0; pointer-events: none; opacity: 0; mix-blend-mode: screen; transition: opacity .4s ease; background: radial-gradient(280px circle at var(--shelf-glow-x,50%) var(--shelf-glow-y,50%), rgba(255,214,140,.10), rgba(255,214,140,.03) 45%, transparent 62%); }
.shelf-cursor-glow.is-active { opacity: 1; animation: shelfGlowFlicker 3.1s ease-in-out infinite; }
@keyframes shelfGlowFlicker {
  0%, 100% { filter: brightness(1); }
  30% { filter: brightness(.74); }
  55% { filter: brightness(1.16); }
  78% { filter: brightness(.86); }
}
/* Brightens one spine's own binding bands when the cursor passes near it
   (updateSpineSheens in render-library.js sets --spine-glow-x/y and toggles
   .is-near, only for spines within SPINE_GLOW_RADIUS of the cursor — every
   other spine on the shelf never has this background recomputed at all).
   mix-blend-mode:overlay (not screen, like the ambient cursor glow) so it
   reads as light *catching* the existing bands/cover gradient — brightening
   what's already there — rather than another flat color laid over it.
   Sits behind the label tag/label text/page-strip in DOM order (see
   spineButton) so the warm light plays across the leather without ever
   dimming legibility of the title. .book-spine's own isolation:isolate
   keeps this blend scoped to that one spine's own painted layers. */
/* Shared base for the cursor-reactive "catches the light" sheen — used on
   both book spines and scrolls (see .book-spine-sheen/.shelf-scroll-sheen
   right below for the two shape-specific overrides). Everything about the
   *behavior* (position tracking, blend mode, fade timing, gradient) is
   identical between the two; only how each one is clipped to its own
   silhouette differs, since a book spine's box is a rounded rectangle and
   a scroll's is a much wider box than the actual rolled-parchment shape
   drawn inside it. */
.shelf-glow-sheen { position: absolute; z-index: 2; inset: 0; pointer-events: none; opacity: 0; mix-blend-mode: overlay; background: radial-gradient(115px circle at var(--spine-glow-x,50%) var(--spine-glow-y,50%), rgba(255,238,200,1) 0%, rgba(255,225,170,.9) 40%, transparent 76%); transition: opacity .25s ease; }
.shelf-glow-sheen.is-near { opacity: 1; }
/* border-radius:inherit — without it this fills its inset:0 box as a plain
   rectangle, ignoring .book-spine's own rounded corners entirely (a
   descendant's background only respects an ancestor's border-radius if the
   ancestor clips it via overflow:hidden, or if the descendant has a
   matching radius of its own). .book-spine can't use overflow:hidden for
   this, the same reason .library-shelf-frame couldn't (round three of
   fixes, above): its own box-shadow includes a non-inset outer drop
   shadow, which overflow:hidden would clip along with everything else.
   Matching the radius directly on this element clips just the sheen's own
   background to the spine's rounded silhouette without touching the
   ancestor at all — so the highlight near a corner reads as light hugging
   the book's actual rounded edge instead of poking past it as a sharp
   square corner. */
.book-spine-sheen { border-radius: inherit; }
/* A scroll's own button box is much wider than the rolled-parchment shape
   actually drawn inside it (scroll.png's tube sits centered in a box
   cropped down from a much taller, wider source canvas — see the
   .shelf-scroll-img comment above), so unlike a book spine this can't
   just inherit the box's own border-radius — a rectangular glow would
   visibly float around the sides of the (much narrower) scroll. Clipped
   instead to #scroll-clip (defined once in library/index.html,
   objectBoundingBox units so the one definition scales to every scroll
   regardless of its rendered size) — a gently-rounded rect approximating
   scroll.png's own silhouette *as rendered after the object-fit:cover
   crop* (its true opaque bounds, measured directly from the asset's
   alpha channel, then re-expressed in the cropped/rendered box's own
   coordinate space — the raw image's fractions alone would be wrong
   here specifically because of that crop). Round 44: this single shape
   now covers the scroll's full silhouette including both rolled ends,
   used by both the cursor sheen and the candle glow below — the old
   two-clip-path split (a tighter tube-only shape for the cursor, a
   wider one including the caps for the candle) existed to work around
   the *hand-drawn* SVG's own layered draw order, which doesn't apply to
   one flat raster image; one shape for both is simpler and correct now
   that there's nothing left to differentiate. */
.shelf-scroll-sheen { clip-path: url(#scroll-clip); }
/* A book/scroll's own candlelight warming used to be a second DOM
   overlay span here (.shelf-item-candlelight, mix-blend-mode:overlay,
   driven by wireCandleSheens in render-library.js), same as the ambient
   flame bloom below used to be one .shelf-lamp-ambient-glow div per
   candle. Both are retired now: keeping a second, DOM-based system alive
   purely for desktop while mobile used canvas meant genuinely two
   different systems to maintain and keep visually in sync — the two
   <canvas> elements below now draw both the ambient bloom and the
   per-book tint for every screen size, one draw call instead of one
   element per candle plus one per book it reaches. See drawGlow/
   wireGlowActivation in render-library.js for the full reasoning; same
   two z-index slots (0 for the ambient bloom, behind every
   .book-spine/.shelf-scroll at z-index:1; 2 for the book tints, above
   them) the DOM version used, so the stacking looks identical. The tint
   canvas still needs mix-blend-mode itself: canvas's internal
   compositing (globalCompositeOperation, used inside drawGlow) only
   blends shapes against each other *within that canvas's own bitmap* —
   getting the drawn tint to actually blend against the real book art
   underneath still needs the browser's own blend-mode compositing on
   the canvas *element*, same mechanism the old DOM divs used, just
   applied once for every book at once instead of once per book. A
   scroll's own tint shape is hand-built in JS (clipToItemShape) instead
   of referencing #scroll-clip directly — canvas can't reference an SVG
   clipPath the way a DOM element's clip-path can (.shelf-scroll-sheen
   below, the unrelated cursor-hover overlay, still does). */
.library-glow-canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }
.library-glow-canvas--bloom { z-index: 0; }
/* overlay, deliberately, despite the cost. Compositing this normally was
   tried and reverted: any non-normal blend makes the compositor re-read
   the backdrop across the layer's bounds, so normal is materially cheaper
   — but overlay is what preserves each cover's own luminance, so light
   reads as falling on the art rather than being washed over it. Plain
   alpha averaged everything toward the tint colour and the books came out
   flat. Two attempts to claw the contrast back (a filter on the spine, a
   cut-out over the title) were both worse than simply paying for the
   blend. The 22x raster reduction on these canvases stands regardless,
   and the redraw is dirty-rected, which bounds the damaged area the
   compositor has to re-blend. */
.library-glow-canvas--tint { z-index: 2; mix-blend-mode: overlay; }
/* A small candle-cluster prop, prepended to the first shelf row only
   (see render-library.js) so it stays put regardless of how many rows
   the shelf grows to. Flame + glow flicker independently via slightly
   different keyframe timings so they don't read as a single mechanical
   pulse. Round forty: a spot now holds 1-3 individual candles (each its
   own .shelf-lamp-candle, see below) instead of always exactly one, so
   .shelf-lamp is a flex row of those rather than positioning a single
   body/flame pair directly — width is intrinsic (no fixed px) since a
   spot's real width now depends on how many candles it was randomly
   given. */
.shelf-lamp { position: relative; z-index: 1; flex: 0 0 auto; align-self: flex-end; display: flex; align-items: flex-end; gap: 3px; height: 100px; margin-right: 2px; cursor: pointer; }
.shelf-lamp:focus-visible { outline: 2px solid rgba(244,185,66,.8); outline-offset: 3px; border-radius: 4px; }
/* Mobile-only, non-interactive candles (buildShelfLampGroup, render-
   library.js — no role/tabindex/click handler there anymore) never
   receive a real click, but a desktop browser window resized narrow
   enough to hit the same breakpoint still has a mouse — this just keeps
   the cursor from implying these are clickable when they aren't. */
.shelf-lamp--static { cursor: default; }
/* A shelf row's candle spot alternates which end it sits on (see
   layoutShelf() in render-library.js) — this just flips the margin so it
   hugs whichever side it was appended to instead of always the left. */
.shelf-lamp--right { margin-right: 0; margin-left: 2px; }
/* One candle within a cluster. Full height of the .shelf-lamp container
   (not just its own candle body's height) so every candle's own
   bottom:0 positioning below lands at the same shelf-surface line
   regardless of how tall that particular candle is — the height
   difference between candles in a cluster comes entirely from
   --candle-h (set inline per candle in buildShelfLampGroup, inherited
   down to .shelf-lamp-body/-flame below it), not from this wrapper's
   own size. Slightly narrower once a spot holds more than one candle,
   so a 2-3 cluster still reads as compact rather than spreading out —
   matches the reference image's tightly-grouped look. */
.shelf-lamp-candle { position: relative; width: 18px; height: 100%; }
.shelf-lamp--count-2 .shelf-lamp-candle, .shelf-lamp--count-3 .shelf-lamp-candle { width: 14px; }
.shelf-lamp-body { position: absolute; left: 50%; bottom: 0; transform: translateX(-50%); width: 100%; height: var(--candle-h,58px); border-radius: 3px 3px 2px 2px; background: linear-gradient(100deg,#f6efd8 0%,#eaddb4 45%,#d4c48e 100%); box-shadow: inset -3px 0 6px rgba(120,100,60,.3), inset 2px 0 4px rgba(255,255,255,.55), 0 10px 16px rgba(0,0,0,.4); }
.shelf-lamp-body::before { content: ""; position: absolute; left: 50%; top: -10px; transform: translateX(-50%); width: 2px; height: 11px; background: #2b2317; }
/* z-index:1 so the flame paints above the wick (.shelf-lamp-body::before)
   where they overlap at the candle's tip — the flame markup comes before
   .shelf-lamp-body in the DOM (render-library.js), and .shelf-lamp-candle
   establishes its own local stacking context (position:relative, and
   z-index:1 here creates one), so without this the later-painted wick/
   body was covering the flame instead of the flame sitting over top of
   the wick. bottom is relative to this candle's own --candle-h (was a
   flat 64px = the original fixed 58px body + 6px of wick-tip clearance;
   now the same relationship expressed per-candle so a shorter candle in
   a cluster still gets its flame sitting right at its own wick tip). */
.shelf-lamp-flame { position: absolute; z-index: 1; left: 50%; bottom: calc(var(--candle-h,58px) + 6px); transform: translateX(-50%); width: 7px; height: 13px; opacity: 1; visibility: visible; transition: opacity .35s ease, visibility 0s linear 0s; }
/* Round 43: the actual flicker (shape/color, and the flicker's own
   opacity+transform wobble) now lives entirely on this ::before pseudo,
   not on .shelf-lamp-flame itself. Previously both the flicker's
   continuous opacity animation AND the on/off snuff/relight opacity
   toggle fought over the same property on the same element — extinguish
   worked fine (it explicitly set animation:none first, letting the
   transition take over), but relight re-attached the animation, and a
   *running* CSS animation always wins that property over a transition
   regardless of duration, so the flame just snapped back in with no
   fade at all. Splitting the two concerns onto two elements removes the
   conflict rather than trying to out-time it: the outer element's
   opacity is now touched by nothing but the is-unlit toggle below, so
   its transition always applies, both directions, every time. The
   pseudo doesn't need its own left:50%/translateX(-50%) centering —
   inset:0 already matches the parent's box exactly — so the keyframes'
   old translateX(-51%)/-49% wobble (perturbations *of* that -50% base)
   were rebased to a plain ±1% jitter around 0 instead; same amplitude,
   no anchor to carry over. --flame-delay is a per-candle custom
   property (set inline in render-library.js's buildShelfLampGroup(), a
   negative value so each candle starts partway through the cycle and
   they don't all flicker in lockstep) — read here via animation-delay,
   since an *inline* animation-delay set from JS can only target a real
   element, never a ::before, and the flicker moved to one. */
.shelf-lamp-flame::before { content: ""; position: absolute; inset: 0; border-radius: 50% 50% 50% 50% / 65% 65% 35% 35%; background: radial-gradient(circle at 50% 35%,#fff3c4,#f4b942 55%,#c9701c 88%); animation: lampFlicker 2.4s ease-in-out infinite; animation-delay: var(--flame-delay,0s); }
/* Clicking/tapping the candle (.shelf-lamp, wired in render-library.js)
   snuffs it out — the outer's opacity transition (above) fades the flame
   cleanly in *both* directions. The intent below has always been to stop
   paying the pseudo's repaint cost once the flame is fully hidden, and
   not to make the fade work (that's the outer element's job alone), but
   `animation: none` applied on the *first* frame of the snuff rather than
   the last: lampFlicker runs opacity 1 -> .82 -> 1 -> .88 -> 1 with a
   .93-1.06 scale wobble, and every candle sits at a random phase of it
   (a negative per-candle --flame-delay, see buildShelfLampGroup), so
   cutting the animation dead jumped the flame from wherever it happened
   to be straight to the keyframe base, in one frame, before any of the
   350ms fade had run. Relighting jumped it back to a random phase the
   same way. That pop is a real part of the candle-tap flash the
   ?perfdebug reports keep marking. Expressed as a *delayed* visibility
   change instead: visibility is discrete but transitionable, so a 0s
   change delayed by the fade's own .35s lands exactly when the flame is
   already at opacity 0 — nothing to see, nothing painted after it, and
   no discontinuity while it's still visible. Relight drops the delay
   back to 0s (the base rule above) so the flame is paintable again
   immediately. A visibility:hidden subtree isn't painted at all, so the
   still-"running" animation underneath costs no more than animation:none
   did. */
.shelf-lamp-flame.is-unlit { opacity: 0; visibility: hidden; transition: opacity .35s ease, visibility 0s linear .35s; }
/* The candle's own ambient bloom used to be one .shelf-lamp-ambient-glow
   div per candle here (built + positioned by wireLampGlow() in
   render-library.js), sized off --lamp-glow-box/--lamp-glow-scale and
   colored via --lamp-glow-alpha, all set inline per glow div. Retired
   along with .shelf-item-candlelight above — see that rule's own comment
   for why. Both platforms now draw this via .library-glow-canvas--bloom
   instead (drawGlow/drawBloomFrame in render-library.js), reading the
   same LAMP_GLOW_BASE_SIZE/CANDLE_GROUP_SCALE/LAMP_GLOW_ALPHA constants
   the old DOM version read into those custom properties, so the look is
   unchanged — only how it's produced is different. */
/* Round 43: rebased off translateX(-50%) — that was the flame's own
   *centering* transform (left:50% + translateX(-50%), the standard
   trick), with these keyframes perturbing it by tiny amounts
   (-51%/-49%) for the wobble. Now that the flicker lives on a ::before
   that fills its parent exactly via inset:0 (no centering transform of
   its own to perturb), the equivalent resting state is translateX(0),
   so the same ±1%-of-own-width jitter is now expressed directly around
   0 instead of around -50% — same visual wobble amplitude, no anchor
   to carry over. */
@keyframes lampFlicker {
  0%, 100% { opacity: 1; transform: scale(1); }
  22% { opacity: .82; transform: scale(.93) rotate(-2deg); }
  46% { opacity: 1; transform: scale(1.06) translateX(1%); }
  69% { opacity: .88; transform: scale(.96) translateX(-1%); }
  84% { opacity: 1; transform: scale(1.02); }
}
/* top/bottom overhang (not inset:0) because the drift below now moves the
   whole layer via transform instead of shifting background-position — the
   box has to already cover past the panel's edges by at least as much as
   it ever translates, or the animation would reveal a gap at the trailing
   edge. Percentage-based dot positions below are relative to this now-
   slightly-taller box rather than the panel exactly, which shifts them by
   an imperceptible amount; not worth the cost of keeping them pinned to
   the panel's own bounds. */
.dust-field { position: absolute; z-index: -1; top: -70px; right: 0; bottom: -70px; left: 0; pointer-events: none; opacity: .7; will-change: transform; background-image:
    radial-gradient(3.2px 3.2px at 10% 22%, rgba(230,223,205,.6), transparent 65%),
    radial-gradient(2.6px 2.6px at 28% 68%, rgba(230,223,205,.48), transparent 65%),
    radial-gradient(3.4px 3.4px at 52% 32%, rgba(230,223,205,.46), transparent 65%),
    radial-gradient(2.8px 2.8px at 71% 58%, rgba(230,223,205,.54), transparent 65%),
    radial-gradient(3px 3px at 88% 24%, rgba(230,223,205,.4), transparent 65%),
    radial-gradient(3.2px 3.2px at 42% 84%, rgba(230,223,205,.42), transparent 65%),
    radial-gradient(2.6px 2.6px at 95% 74%, rgba(230,223,205,.36), transparent 65%);
  animation: dustDrift 26s linear infinite;
}
/* Was animating background-position, which forces a real repaint of the
   dust layer on every frame — and that layer is inset:0 on the shelf
   panel, which keeps getting taller as more rows/books are added over
   time, so the repaint cost had no ceiling. transform is compositor-only:
   the browser rasterizes the (now slightly oversized) layer once and the
   GPU just slides the existing bitmap around afterward, at a fixed cost
   regardless of how tall the panel eventually grows. The seven motes now
   drift together as one instead of each along its own slightly different
   path — a real, deliberate trade of a subtle visual nuance for a
   meaningful, permanent performance win. */
@keyframes dustDrift {
  from { transform: translateY(0); }
  to { transform: translateY(-50px); }
}
/* New shelf rows stack below the previous one as more books are added
   (each row gets its own wood ledge — see .library-shelf-ledge below,
   built + positioned per row by positionShelfLedges() in JS). */
.library-shelf-stack { display: flex; flex-direction: column; gap: clamp(28px,3.6vw,46px); }
/* content-visibility:auto used to skip layout/paint/composite work for
   off-screen rows here, but real device testing showed it causing worse
   problems than it solved: visible pop-in/pop-out of rows while
   scrolling, and books appearing to vanish after the book modal closed
   (content-visibility can suppress a subtree's rendering in ways that
   don't always recover cleanly after other DOM changes). Removed
   entirely — correctness over this particular optimization. The
   dominant scroll-performance fixes (layout-thrashing during book-open,
   the touchstart-during-scroll bug, bounded glow paint area, etc.) are
   unaffected by this and remain in place.
   overflow is left at its default (visible): it used to be
   overflow-x:auto as a safety net for a single item wider than the row,
   but layoutShelf's width-aware packing (render-library.js) never
   actually lets that happen at any real screen size, and an
   overflow:auto row triggered a visible scrollbar the moment the wood
   ledge was extended past the row's own box toward the cabinet's true
   edge — auto scrollbars appear for *any* overflowing descendant. */
.library-shelf { position: relative; display: flex; align-items: flex-end; gap: clamp(8px,1.2vw,16px); min-height: clamp(220px,26vw,320px); padding: 0 clamp(10px,2vw,24px) 26px; }
/* The wood ledge used to be a .library-shelf::after pseudo-element
   positioned with clamp()/calc() math meant to cancel out the row's own
   padding plus the panel's padding, so it would stretch all the way to
   the cabinet's true edge. Three attempts at that math failed in real
   testing, and without a real browser available to verify the
   containing-block/padding-edge behavior directly, guessing a fourth
   time wasn't worth it. Replaced with a real element (.library-shelf-ledge,
   built + positioned by positionShelfLedges() in render-library.js) sized
   from the panel's and each row's actual getBoundingClientRect(). Visual
   styling only lives here; left/width/top are set inline in JS from real
   measurements. */
/* z-index:-1 (not auto) is deliberate: .library-shelf-panel establishes
   its own stacking context (isolation:isolate), and within a stacking
   context a *positioned* z-index:auto descendant always paints after
   in-flow, non-positioned content regardless of DOM order (per the
   CSS2.1 painting-order rules) — so without this, an absolutely
   positioned ledge would paint *over* the book spines sitting in
   .library-shelf-stack (itself non-positioned/static) no matter where
   in the DOM it was inserted. A negative z-index puts it in the earlier
   "behind in-flow content" paint step instead, so books/candles resting
   on it are correctly drawn on top. */
/* height is purely decorative now (round thirty-five) — positionShelfLedges()
   anchors this element's top to the row's actual measured item-bottom, not
   to this height value, so changing it just makes the visible board thicker
   below that line without ever throwing off alignment with the books above
   it. No matching constant to keep in sync in render-library.js anymore. */
/* Warmed up (round thirty-six, alongside the cabinet's own border/base
   gradient above) — green was sitting too close to red for a stained-
   wood read, closer to brass than walnut/spruce lumber. Pulled further
   apart on both stops. */
.library-shelf-ledge { position: absolute; z-index: -1; height: 42px; border-radius: 6px; background: linear-gradient(180deg,#45301f,#1f150d); box-shadow: 0 18px 34px rgba(0,0,0,.4), inset 0 2px 0 rgba(255,255,255,.05); pointer-events: none; }
/* The first background layer is 3 faint raised "binding bands" — the
   ridges a real sewn-binding book shows across its spine — placed at
   fixed 25/50/75% stops (not a repeating-linear-gradient anymore, which
   produced 4 bands with the last one crowding the bottom edge) so the
   count is always exactly 3, evenly spaced, regardless of the spine's
   actual height (short/standard/tall classes below). The second layer is
   the actual cover-color gradient underneath. */
/* Round 59 — a single, permanent "there's room here" slot appended to the
   very end of the shelf's real items (see buildEmptyShelfSlot() in
   render-library.js), always exactly one regardless of how many real
   entries currently exist. A plain hollow outline at roughly a book-
   spine's own footprint, deliberately un-lit: no .book-spine/.shelf-
   scroll class means it's invisible to drawGlow's/wireShelfCursorGlow's
   reactive-glow queries (both target those two classes explicitly) and
   to spineGeometryCache, so no candle or cursor light ever brightens it —
   flat and static no matter what's happening around it, on purpose,
   since per the user's own call this isn't meant to imply a specific
   waiting text, just that the shelf has more room. */
.library-shelf-empty-slot { flex: 0 0 auto; align-self: flex-end; display: flex; align-items: center; justify-content: center; width: clamp(56px,5.2vw,78px); height: clamp(190px,22vw,270px); border: 2px dashed rgba(229,223,207,.3); border-radius: 6px 8px 8px 6px; background: rgba(0,0,0,.16); pointer-events: none; }
/* Round 59 follow-up — a faint centered glyph, per the user's own request
   after seeing the plain outline live: "hard to see" on both a thin
   border and (relatively) a small mobile footprint, same underlying
   problem at two sizes — this, the thicker border above, and the dark
   fill above all add visual weight rather than trying to literally
   resize the box differently per breakpoint (its width/height clamps
   already match .book-spine's exactly at both the desktop base and the
   @media(max-width:820px) override below, so there was nothing actually
   un-responsive about the box itself to fix). clamp()'d off vw directly
   (not tied to the box's own width) so it scales independently and
   never risks overflowing the box at either end. */
.library-shelf-empty-slot::after { content: "?"; font: 700 clamp(22px,2.6vw,32px)/1 "Stronghold Print","Iowan Old Style","Baskerville",serif; color: rgba(229,223,207,.24); }
.book-spine { position: relative; isolation: isolate; z-index: 1; flex: 0 0 auto; min-width: 0; width: clamp(56px,5.2vw,78px); height: clamp(190px,22vw,270px); border: 0; border-radius: 6px 8px 8px 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; padding: 18px 0 22px;
  /* Bottom-anchored (round 30) so the hover "pulled out" transform below —
     lift, tilt, and now a slight scale-up — pivots from the point where
     the book meets the shelf, not its own visual center. Growing from the
     center would have made the book expand both up *and* down past the
     shelf ledge on hover, breaking the illusion that its base is still
     resting there while the rest of it comes toward the viewer. */
  transform-origin: bottom center;
  /* Round 60 — the plain 2-stop cover gradient is now the base/fallback
     for every design; the striped-band look below used to be baked in
     unconditionally here, and is now its own design (.book-spine--design-
     banded, further down) so the other nine designs get a clean cover to
     build their own cover treatment on top of. */
  background: linear-gradient(180deg,var(--book-a,#8a6f4a) 0%,var(--book-b,#5e4a30) 100%);
  /* 4th shadow layer (round 30) is a tight, high-opacity "ground contact"
     shadow right at the base — distinct from the 3rd, broader ambient
     shadow that already existed. On hover (below) it fades down to nearly
     nothing, since a book lifted off the shelf shouldn't still cast a
     shadow as if it were still touching it; it fades back in the moment
     the cursor leaves, same transition, no JS involved. */
  box-shadow: 2px 0 0 rgba(0,0,0,.18) inset, -2px 0 6px rgba(255,255,255,.06) inset, 0 14px 26px rgba(0,0,0,.32), 0 4px 6px rgba(0,0,0,.5); transition: transform .34s cubic-bezier(.2,.7,.2,1), box-shadow .34s ease; }
.book-spine.book-spine--short { height: clamp(150px,17vw,210px); }
.book-spine.book-spine--tall { height: clamp(220px,27vw,310px); }
/* Width variety is independent of the height classes above (see
   spineWidthClass() in render-library.js — cycled by shelf position, not
   label length), so a spine can be e.g. both --tall and --narrow. */
.book-spine.book-spine--narrow { width: clamp(48px,4.4vw,64px); }
.book-spine.book-spine--wide { width: clamp(62px,5.8vw,86px); }
/* Round 60 — design 1 of 10 (see SPINE_DESIGNS in render-library.js): the
   original always-on banded-leather look, now opt-in via this modifier
   so the other nine designs can paint their own cover treatment on the
   plain 2-stop gradient .book-spine sets as its base background above.
   Percentage stops (not px) so the band positions scale automatically
   with whichever height class (--short/--tall) this spine also carries —
   no separate rule needed per height class. */
.book-spine.book-spine--design-banded { background:
    linear-gradient(180deg,
      transparent 0%, transparent 24.4%,
      rgba(0,0,0,.22) 24.4%, rgba(0,0,0,.22) 25%,
      rgba(255,255,255,.1) 25%, rgba(255,255,255,.1) 25.6%,
      transparent 25.6%, transparent 49.4%,
      rgba(0,0,0,.22) 49.4%, rgba(0,0,0,.22) 50%,
      rgba(255,255,255,.1) 50%, rgba(255,255,255,.1) 50.6%,
      transparent 50.6%, transparent 74.4%,
      rgba(0,0,0,.22) 74.4%, rgba(0,0,0,.22) 75%,
      rgba(255,255,255,.1) 75%, rgba(255,255,255,.1) 75.6%,
      transparent 75.6%, transparent 100%),
    linear-gradient(180deg,var(--book-a,#8a6f4a) 0%,var(--book-b,#5e4a30) 100%);
}
/* scale(1.045) (round 30) — a small enough increase to read as "coming
   toward you" rather than an obvious resize; anything much larger started
   fighting with the label-tag/label sizing, which is measured from the
   spine's *resting* dimensions in sizeLabelTags() and doesn't re-measure
   on hover.
   .is-opening (round 32) — a plain class carrying the identical visual,
   added by openBook() in render-library.js right when a spine is clicked
   and removed again in closeBook(). Needed because :hover alone doesn't
   survive the click: the instant .book-overlay covers the spine (same
   tick as the click), the browser resolves :hover to whatever's now
   topmost under the pointer, which is the overlay — not the spine
   anymore — so a real :hover-only transition would start reversing back
   to rest immediately, and the "pulled off the shelf" motion would
   barely register before vanishing under the opening book. openBook()
   delays actually building the overlay/modal by a short beat specifically
   so this class-driven state gets a real, visible window first. */
.book-spine:hover, .book-spine:focus-visible, .book-spine.is-opening { transform: translateY(-14px) rotate(-1deg) scale(1.045); box-shadow: 2px 0 0 rgba(0,0,0,.18) inset, -2px 0 6px rgba(255,255,255,.09) inset, 0 24px 40px rgba(0,0,0,.4), 0 4px 6px rgba(0,0,0,.08); }
/* Uses ::after (not ::before, which .glow-card already claims for its pointer
   hover-glow) so the ribbon renders independently and stays visible at all
   times, not just on hover. */
/* top:0 (not a negative overhang) so the ribbon's own top edge is always
   flush with its spine's top edge, regardless of that spine's height
   class — a fixed overhang looked fine on some spines but left a visible
   gap on others once .book-spine--short/--tall (different heights) and
   .book-spine--narrow/--wide (different widths) were both in play.
   right is a fixed px value rather than a percentage for the same
   reason: a %-based offset sits proportionally closer to the spine's
   fixed 8px top-right border-radius on narrower spines, letting the
   ribbon's flat rectangular top overhang past where the corner has
   already curved away — 10px keeps it clear of that curve on every
   width class, from the narrowest mobile spine on up. */
.book-spine.is-read::after { content: ""; position: absolute; z-index: 3; top: 0; right: 10px; width: 11px; height: 24px; opacity: 1; background: var(--amber); box-shadow: 0 5px 12px rgba(0,0,0,.35); clip-path: polygon(0 0,100% 0,100% 100%,50% 76%,0 100%); }
/* Round 51 gave a translated scroll (see isTranslated()/scrollButton() in
   render-library.js — most scrolls are still the Elder's untracked
   present-day notes and never get this class at all) the exact same
   read-bookmark ribbon as .book-spine.is-read above. Round 53 replaced
   that: a page-corner ribbon is a book-binding thing, and reads oddly on
   an object that's a single rolled sheet with no pages/corners to mark —
   per the user's own framing, a scroll wants "a stamp or something"
   instead. Restyled as a small wax-seal dot, reusing the exact palette/
   gradient/inset-shadow recipe .book-modal .popup-close already
   established for the seal-stamp close button (same "pressed wax"
   language, so this reads as the same in-universe object literally
   sealed once translated, not just a re-skinned ribbon). Sits centered
   over the ribbon-wrap area near the top of the scroll art (roughly
   where the bow crosses in scroll.png) rather than reusing the ribbon's
   old top-right anchor — a stamp centered on the object it's certifying
   reads more like an actual wax seal than a corner flag would.
   Position is an estimate, not pixel-measured against the live art (no
   way to render the actual asset in this sandbox) — nudge top/left here
   if it doesn't land on the wrap once it's live. */
.shelf-scroll.is-read::after {
  content: "";
  position: absolute;
  z-index: 3;
  top: 18%;
  left: 50%;
  transform: translate(-50%,-50%);
  width: 15px;
  height: 15px;
  border-radius: 48% 52% 51% 49% / 53% 47% 55% 45%;
  background: radial-gradient(circle at 34% 30%, #8f3224 0%, #5c1810 62%, #3c0f09 100%);
  box-shadow: 0 2px 5px rgba(0,0,0,.45), inset 0 1px 2px rgba(255,180,140,.35), inset 0 -2px 3px rgba(0,0,0,.5);
}
/* "New since your last visit" flag (buildNewBadge in render-library.js)
   — a real element, not another pseudo-element: .book-spine's ::before
   is already .glow-card's hover glow and ::after is the is-read ribbon
   above (top-right), so there's no free pseudo-element slot left on
   spines, and this stays one function shared with .shelf-scroll (which
   has no pseudo-elements claimed at all) rather than two separate
   badge implementations. Sits top-left, clear of both the top pages
   strip and the top-right read ribbon. */
.shelf-new-badge { position: absolute; z-index: 3; top: 10px; left: -3px; padding: 2px 6px 2px 5px; border-radius: 0 3px 3px 0; background: var(--amber); color: var(--ink); font: 700 7px/1 "Stronghold Print","Menlo","Consolas",monospace; text-transform: uppercase; letter-spacing: .08em; box-shadow: 0 3px 8px rgba(0,0,0,.35); pointer-events: none; }
/* A second, more eye-catching treatment layered on top of the badge
   above for the same isNew signal (getNewSlugs) — a slow pulsing amber
   glow around the whole item, plus a few small twinkling glints
   scattered over it (buildNewSparkles, render-library.js — real
   elements, same reasoning as .shelf-new-badge's own comment: no free
   pseudo-element slot left on either button type). Pure CSS animation,
   no per-frame JS; the *, *::before, *::after prefers-reduced-motion
   rule further down already collapses all of this to a single
   near-instant frame for anyone who's asked for less motion, so nothing
   extra is needed here for that. Stops appearing the next time
   getNewSlugs' own last-visit timestamp rolls forward — same lifetime
   as the badge, not something this glow tracks independently. */
@keyframes newItemGlow {
  0%, 100% { box-shadow: 0 0 0 rgba(185,163,101,0); }
  50% { box-shadow: 0 0 24px 4px rgba(185,163,101,.55); }
}
.book-spine.is-new { animation: newItemGlow 2.6s ease-in-out infinite; }
/* Scrolls can't use the same box-shadow treatment — a plain box-shadow
   follows .shelf-scroll's own rectangular DOM box, not the tapered
   parchment-tube silhouette scroll.png actually paints (there's no CSS
   clip-path/border-radius on the button itself; the visual "roundedness"
   is purely the PNG's own alpha transparency), so it read as a generic
   rectangular glow with none of the scroll's actual shape. filter:
   drop-shadow() solves this properly — unlike box-shadow, it follows
   the rendered content's real alpha silhouette. Applied to
   .shelf-scroll-img specifically, not .shelf-scroll itself, since the
   button already carries its own filter: drop-shadow(...) pair (the
   permanent drop-cast shadow, plus a different pair on :hover/
   .is-opening) — animating that same property there would fight the
   hover state the moment a still-new scroll gets hovered, the same
   class of bug .reveal's transform override caused elsewhere on this
   page. The image has no filter of its own, so this is a clean, fully
   independent second one. */
@keyframes newItemGlowScroll {
  0%, 100% { filter: drop-shadow(0 0 0 rgba(185,163,101,0)); }
  50% { filter: drop-shadow(0 0 12px rgba(185,163,101,.85)); }
}
.shelf-scroll.is-new .shelf-scroll-img { animation: newItemGlowScroll 2.6s ease-in-out infinite; }
@keyframes newItemSparkle {
  0%, 100% { opacity: 0; transform: scale(.4) rotate(0deg); }
  50% { opacity: 1; transform: scale(1) rotate(45deg); }
}
.new-sparkle { position: absolute; z-index: 4; width: 10px; height: 10px; background: radial-gradient(circle, #fff8e6 0%, var(--amber) 60%, transparent 78%); clip-path: polygon(50% 0%, 61% 39%, 100% 50%, 61% 61%, 50% 100%, 39% 61%, 0% 50%, 39% 39%); pointer-events: none; animation: newItemSparkle 2.6s ease-in-out infinite; }
.new-sparkle--a { top: 14%; right: 12%; animation-delay: 0s; }
.new-sparkle--b { top: 55%; left: 10%; width: 7px; height: 7px; animation-delay: .9s; }
.new-sparkle--c { bottom: 16%; right: 22%; width: 8px; height: 8px; animation-delay: 1.7s; }

/* Round 60 — designs 2-10 (see SPINE_DESIGNS in render-library.js). Every
   size/position below is a clamp() or percentage resolved against the
   spine's own box (.book-spine has explicit width/height, so percentage
   width/height on an absolutely positioned descendant resolves against
   that, not the viewport) rather than a fixed px value — the mockup this
   was built from used fixed px throughout and only ever showed one fixed
   spine size, so nothing there was proven to hold up across the real
   .book-spine/--short/--tall/--narrow/--wide range (roughly 40-86px
   wide, 120-310px tall) or the continuous clamp() scaling between
   breakpoints. Sizing everything relative to the spine's own resolved
   box means these designs need no separate @media override the way the
   spine's own width/height clamps do — they inherit whatever size the
   spine already is at any viewport width for free. */

/* Design 3: reinforced corners — four small brackets plus rivets, no
   bands. Bracket size/offset both derive from spine width (clamp's
   preferred value is a %), not height, since width is the tighter
   constraint across the size range and keeping both axes tied to it
   keeps the bracket roughly square instead of stretching. */
.book-spine-corner { position: absolute; width: clamp(9px,20%,14px); height: clamp(9px,20%,14px); border: 2px solid rgba(190,170,120,.55); box-shadow: 0 1px 2px rgba(0,0,0,.4); pointer-events: none; }
.book-spine-corner--tl { top: clamp(3px,5%,6px); left: clamp(2px,4%,4px); border-right: 0; border-bottom: 0; border-radius: 4px 0 0 0; }
.book-spine-corner--tr { top: clamp(3px,5%,6px); right: clamp(2px,4%,4px); border-left: 0; border-bottom: 0; border-radius: 0 4px 0 0; }
.book-spine-corner--bl { bottom: clamp(3px,5%,6px); left: clamp(2px,4%,4px); border-right: 0; border-top: 0; border-radius: 0 0 0 4px; }
.book-spine-corner--br { bottom: clamp(3px,5%,6px); right: clamp(2px,4%,4px); border-left: 0; border-top: 0; border-radius: 0 0 4px 0; }
.book-spine-rivet { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: radial-gradient(circle at 35% 30%, #e8d9ad, #8a7440 70%); box-shadow: 0 1px 1px rgba(0,0,0,.5); pointer-events: none; }

/* Design 4: clasped codex — two mirrored straps with gold buckles, title
   pinned equidistant between them via space-between rather than a fixed
   gap, so it stays centered regardless of title length or spine height. */
/* height:100% (not just align-self:stretch, which only reliably gives
   *this* element a definite size — percentage-height resolution for its
   *own children*, i.e. .book-spine-label's max-height:88% below, needs
   an unambiguous definite height on its immediate containing block, and
   relying on stretch alone for that turned out not to hold: a long title
   here rendered with no effective cap at all and spilled past the
   spine's own edges, since a percentage height against an undefined
   parent height simply doesn't apply. Explicit height:100% fixes that
   for real. */
.book-spine-clasp-stack { display: flex; flex-direction: column; align-items: center; justify-content: space-between; width: 100%; height: 100%; }
/* This design's label shares its available height with two straps
   (~8-13px each) instead of having the spine's full height to itself,
   so the generic 88% cap (tuned for a label alone) left it long enough
   to still crowd or overlap the straps even once bounded — see the
   .book-spine-seal-stack comment below for the sigil design's identical
   reasoning. */
.book-spine--design-clasped .book-spine-label { max-height: 62%; }
.book-spine-strap { position: relative; align-self: stretch; height: clamp(8px,6%,13px); background: linear-gradient(180deg,#6b4a28,#4a3018 50%,#6b4a28); box-shadow: 0 1px 0 rgba(255,255,255,.06), 0 -1px 0 rgba(0,0,0,.3), 0 2px 4px rgba(0,0,0,.4); flex: 0 0 auto; }
.book-spine-strap::before, .book-spine-strap::after { content: ""; position: absolute; left: 4px; right: 4px; height: 1px; background: rgba(0,0,0,.35); }
.book-spine-strap::before { top: 2px; }
.book-spine-strap::after { bottom: 2px; }
.book-spine-buckle { position: absolute; left: 50%; top: 50%; transform: translate(-50%,-50%); width: clamp(11px,20%,16px); height: clamp(11px,20%,16px); border-radius: 2px; background: radial-gradient(circle at 35% 30%, #f3e3ad, #c49a56 55%, #8a6a35 100%); box-shadow: inset 1px 1px 2px rgba(255,248,222,.55), inset -1px -1px 2px rgba(58,38,10,.5), 0 1px 3px rgba(0,0,0,.45); }
.book-spine-buckle::before { content: ""; position: absolute; left: 3px; right: 3px; top: 50%; height: 2px; transform: translateY(-50%); background: rgba(60,40,15,.6); }

/* Design 5: cloth title plate — no bands, a bordered recessed plate that
   hugs its own text via padding rather than sizeLabelTags()'s measure-
   and-position pass, so it needs none of that JS (it's never queried:
   sizeLabelTags only ever looks for .book-spine-label-tag, which this
   design doesn't use at all). */
/* height (not max-height!) — plate is a direct child of .book-spine
   (unlike the clasp/seal stacks), so 88% resolves against .book-spine's
   own definite height with no indirection needed for the PLATE's own
   box. But the label inside also has its own `max-height:88%` (base
   .book-spine-label rule), meant to resolve against *this* element —
   and a percentage max-height on a *child* only resolves if its own
   containing block has a definite height, not just a max-height cap.
   With max-height here, the plate's own box was correctly capped, but
   the label's cap silently computed to "none" (no effective limit) —
   so a long title never actually hit its own ellipsis-truncation logic
   at all, it just rendered at full natural size and got hard-cropped
   symmetrically from *both* ends by the plate's overflow:hidden once it
   overflowed, instead of cleanly truncating with "…" at the end like
   every other design does. Switching to a real `height` gives the label
   a definite basis to resolve its own max-height against, so it goes
   back to truncating properly instead of overflowing. Trade-off: the
   plate itself no longer shrinks to hug a short title as snugly as it
   used to (align-items/justify-content:center now centers short titles
   inside a plate that's always 88% tall, rather than a plate that grows
   only as tall as the text needs) — a deliberate, visible style change
   in exchange for fixing a real overflow bug, not a side effect. */
.book-spine-plate { position: relative; display: inline-flex; align-items: center; justify-content: center; height: 88%; overflow: hidden; padding: clamp(10px,3vw,16px) clamp(6px,1.6vw,9px); border: 1px solid rgba(220,205,165,.45); border-radius: 2px; background: rgba(0,0,0,.32); }
.book-spine-plate::before { content: ""; position: absolute; inset: 4px; border: 1px solid rgba(220,205,165,.2); }

/* Design 6: raised cords — five bands clustered near the top third
   instead of three spread evenly, smooth below. Percentage top/height
   already scale with the spine's own height class; cord thickness stays
   a flat 2px at any width, same as a real raised cord would read the
   same regardless of the book's own size. */
.book-spine-cord-cluster { position: absolute; top: 20%; left: 0; right: 0; height: 30%; pointer-events: none; }
.book-spine-cord { position: absolute; left: 0; right: 0; height: 2px; background: rgba(0,0,0,.28); box-shadow: 0 1px 0 rgba(255,255,255,.08); }

/* Design 7: embossed sigil — a recessed (not raised) gold mark grouped
   with the title via gap rather than a fixed offset, so the two can
   never collide regardless of title length (see round 59's clasp/sigil
   overlap fixes for why gap-grouping replaced independent positioning).
   Both the outer ring and the inner mark keep the same dark-top-left/
   light-bottom-right inset convention — a recessed shape's near wall
   (facing the light) is in shadow, the far wall catches a little light —
   and neither casts an outer drop-shadow, since a debossed shape doesn't
   sit above the surface enough to shadow it. */
/* height:100% + justify-content:center — same fix and same reason as
   .book-spine-clasp-stack above: without a definite height here,
   .book-spine-label's max-height:88% doesn't resolve to anything and a
   long title overflows past the spine's own edges instead of getting
   truncated with an ellipsis like every other design already does.
   justify-content:center is new here (the clasp-stack's own
   space-between doesn't need it) because stretching this to 100% height
   would otherwise pack the seal+label toward the top of that full
   height rather than centering the pair as a group, which is what the
   stack's own gap-based grouping was built for in the first place. */
.book-spine-seal-stack { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 16px; width: 100%; height: 100%; }
/* This design's label shares its available height with the seal above
   it (roughly 16-26px, plus the stack's own 16px gap) instead of having
   the spine's full height to itself, so the generic 88% cap left it
   long enough to still crowd the seal even once actually bounded. */
.book-spine--design-sigil .book-spine-label { max-height: 74%; }
/* aspect-ratio:1 instead of a separately-computed height — the two
   would-be-identical clamp(16px,32%,26px) expressions actually resolved
   against two different bases (width% against the seal-stack's own
   width, height% against its height, which are very different
   magnitudes on a spine much taller than it is wide), stretching the
   circle into an oval even with a definite height/width now set on the
   stack above. aspect-ratio sidesteps the mismatch entirely: whatever
   width actually resolves to, height is forced to match it exactly. */
.book-spine-seal { position: relative; width: clamp(16px,32%,26px); aspect-ratio: 1; border-radius: 50%; background: radial-gradient(circle at 40% 35%, #b98f52, #8a6a35 60%, #6b4f28 100%); box-shadow: inset 2px 2px 3px rgba(40,26,8,.6), inset -1px -1px 2px rgba(243,227,173,.4); }
.book-spine-seal-mark { position: absolute; top: 50%; left: 50%; width: 32%; height: 32%; transform: translate(-50%,-50%) rotate(45deg); background: radial-gradient(circle at 35% 30%, #c49a56, #6b4f28 85%); box-shadow: inset 1px 1px 1px rgba(0,0,0,.5), inset -1px -1px 1px rgba(255,235,190,.35); }

/* Designs 8-10 share three small "wear" ingredients — soft fading, a
   rubbed-lighter corner, and a faint age spot — mixed differently per
   design so 8/sun-bleached, 9/frayed and 10/ghost-title don't all read
   as the same texture. All percentage-sized against the spine box. */
.book-spine-fade-patch { position: absolute; border-radius: 50%; pointer-events: none; }
.book-spine-rubbed-corner { position: absolute; width: clamp(10px,20%,16px); height: clamp(10px,20%,16px); border-radius: 50%; background: radial-gradient(circle, rgba(255,247,225,.24), transparent 70%); pointer-events: none; }
.book-spine-foxing-spot { position: absolute; width: clamp(5px,10%,7px); height: clamp(5px,10%,7px); border-radius: 50%; background: radial-gradient(circle, rgba(70,45,20,.34), transparent 75%); pointer-events: none; }
.book-spine-mend-line { position: absolute; width: 1px; height: 14%; background: rgba(255,247,225,.12); pointer-events: none; }
/* Design 9's frayed headband replaces the crisp page-block strip
   (.book-spine-pages, appended in every design) with a rougher woven-
   thread pattern and an irregular dashed bottom edge instead of a clean
   rectangle — same element, same position/height, just a different
   background/border so no separate layout rule is needed. */
.book-spine-pages.book-spine-pages--frayed { background: repeating-linear-gradient(90deg, rgba(255,247,225,.5) 0px, rgba(255,247,225,.5) 1px, rgba(110,92,64,.45) 1px, rgba(110,92,64,.45) 3px); border-bottom: 1px dashed rgba(0,0,0,.35); box-shadow: none; }
/* Design 10's ghost/worn title — the title itself is barely there,
   pressed into the leather rather than inked, as if read so many times
   the ink wore off. Scoped by the design's own modifier class on the
   button (an ancestor of both), not an extra class on the label/tag
   themselves, so it stays a pure CSS override with no JS changes. */
.book-spine--design-ghost .book-spine-label-tag { background: rgba(0,0,0,.18); }
.book-spine--design-ghost .book-spine-label { color: rgba(255,247,225,.4); text-shadow: 1px 1px 1px rgba(0,0,0,.45), -1px -1px 1px rgba(255,255,255,.07); }

/* Design 10 (cracked leather's own numbering: 10th in SPINE_DESIGNS) —
   two branching hairline crack clusters plus one small isolated crack,
   each an actual jagged SVG path (not a straight rotated bar, which
   read as a ruled line rather than a fracture) with a faint offset
   highlight stroke for depth. The <svg> itself is percentage-positioned
   and percentage-sized against the spine box; the jagged path inside is
   drawn in its own fixed viewBox coordinate space and scales to fit via
   the SVG's default preserveAspectRatio, so the crack shape itself
   doesn't need to be redrawn per spine size. */
.book-spine-crack-svg { position: absolute; pointer-events: none; }

/* A translucent dark plate behind the title text — like the label patch
   on a real library binding, and it also fixes readability on lighter
   spine colors (mustard, amber) where cream text on cream-ish leather
   was low-contrast. Deliberately a translucent *dark* overlay rather
   than a solid color: whatever's underneath (each book's own --book-a/
   --book-b gradient) shows through tinted darker, so it automatically
   matches that specific book's color instead of needing a separate
   per-book value computed in JS. Sized in JS (sizeLabelTags() in
   render-library.js) to the label's own actual rendered bounding box
   plus a little padding, not a fixed percentage of the spine — a fixed
   percentage covered nearly the whole spine for short labels like
   "Vol. II" even though the text itself only needed a fraction of that.
   left/top/width/height are set inline per spine once real layout is
   available; the values below are just a sane fallback. */
.book-spine-label-tag { position: absolute; z-index: 1; left: 18%; top: 30%; width: 64%; height: 40%; border-radius: 3px; background: rgba(0,0,0,.26); box-shadow: inset 0 0 0 1px rgba(255,255,255,.08), 0 1px 3px rgba(0,0,0,.3); pointer-events: none; }
/* Round thirty-seven: a little unlit/shadowed by default (filter, not a
   color/opacity change — keeps the underlying rgba() and text-shadow
   untouched, so this is purely a brightness dial). Lifts to full
   brightness under the same three "something's lighting this book"
   conditions: a direct hover/focus/open, the cursor merely *passing
   near* it (.is-near, mirrored onto the button itself in
   updateSpineSheens so this doesn't need a :has() selector reaching
   into the sheen span), or a nearby candle (.is-candlelit, set by
   drawGlow — render-library.js). (Round thirty-eight removed the matching cover-art
   darkening overlay this comment used to reference — see the removed
   .shelf-item-shade — but left this text-only version in place since it
   wasn't part of what the user asked to remove.) */
/* text-overflow:ellipsis deliberately dropped — combined with
   writing-mode:vertical-rl + the rotate(180deg) below, Chromium doesn't
   paint the real "…" glyph here, just a stray trailing dash. Truncation
   is real text now, computed against this element's own rendered box by
   truncateSpineLabels() in render-library.js; overflow:hidden stays as a
   hard backstop for the brief pre-JS moment before that runs. */
.book-spine-label { position: relative; z-index: 2; writing-mode: vertical-rl; transform: rotate(180deg); font: 700 clamp(13px,1.3vw,17px)/1.15 "Stronghold Print","Iowan Old Style","Baskerville",serif; letter-spacing: .01em; color: rgba(255,247,225,.92); text-shadow: 0 1px 2px rgba(0,0,0,.4); max-height: 88%; overflow: hidden; white-space: nowrap; filter: brightness(.74); transition: filter .3s ease; }
.book-spine:hover .book-spine-label, .book-spine:focus-visible .book-spine-label, .book-spine.is-opening .book-spine-label,
.book-spine.is-near .book-spine-label, .book-spine.is-candlelit .book-spine-label { filter: brightness(1); }
/* The page block's "head" — a real book's pages show as a thin band at the
   top of the spine (where the text block peeks out past the cover
   material), not along the vertical sides, which are the front/back cover
   joints instead. Inset slightly from the very left/right edges (not
   full-bleed) — at that sharp an angle you'd actually still be seeing a
   sliver of the cover board's own edge, not the page block, so a flush
   edge-to-edge strip read as if the cover had no thickness at all. */
.book-spine-pages { position: absolute; z-index: 2; top: 0; left: 5px; right: 5px; height: 6px; border-radius: 1px; pointer-events: none; background: linear-gradient(90deg,#cbb98e,#e8dcc0 45%,#cbb98e); box-shadow: 0 1px 0 rgba(0,0,0,.25); }
.library-shelf-footer { position: relative; z-index: 5; display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 18px 0 0; flex-wrap: wrap; }
.library-shelf-note { margin: 0; font: 9px monospace; text-transform: uppercase; letter-spacing: .15em; color: rgba(230,223,205,.42); }
/* "N of M translated" — set by updateProgressIndicator() in
   render-library.js, empty until the shelf's data has loaded (so it
   never shows "0 of 0" mid-fetch). Matches .library-shelf-note's own
   quiet, uppercase-monospace treatment rather than trying to draw
   attention to itself. */
.library-progress { margin: 0; font: 9px monospace; text-transform: uppercase; letter-spacing: .15em; color: rgba(230,223,205,.42); }
/* Opt-in ambient audio toggle — starts off on every load (browsers block
   autoplay-with-sound anyway, so there's no honest way to "remember" an on
   state across visits without a click). A short volume fade in/out is
   applied in render-library.js rather than snapping the loop on/off. */
.shelf-sound-toggle { display: flex; align-items: center; gap: 7px; border: 1px solid rgba(230,223,205,.16); border-radius: 20px; background: rgba(230,223,205,.05); padding: 6px 12px 6px 10px; cursor: pointer; transition: background .2s ease, border-color .2s ease; }
.shelf-sound-toggle:hover, .shelf-sound-toggle:focus-visible { background: rgba(230,223,205,.1); border-color: rgba(230,223,205,.28); }
.shelf-sound-icon { width: 7px; height: 7px; border-radius: 50%; background: rgba(230,223,205,.35); box-shadow: 0 0 0 rgba(244,185,66,0); transition: background .3s ease, box-shadow .3s ease; }
.shelf-sound-toggle.is-on .shelf-sound-icon { background: #f4b942; box-shadow: 0 0 8px 1px rgba(244,185,66,.65); animation: lampFlicker 2.4s ease-in-out infinite; }
.shelf-sound-label { font: 9px monospace; text-transform: uppercase; letter-spacing: .14em; color: rgba(230,223,205,.5); }
.shelf-sound-toggle:hover .shelf-sound-label, .shelf-sound-toggle.is-on .shelf-sound-label { color: rgba(230,223,205,.75); }

/* First-visit sound prompt (wireSoundPrompt in render-library.js) — a
   small, non-blocking card, not a full overlay/modal like .book-overlay
   or .privacy-overlay: this is a one-time nudge, not something that
   should block interaction with the shelf behind it. Starts translated
   down + transparent and slides/fades up once .is-visible is added (a
   moment after load, not instantly — see the JS comment for why). */
.sound-prompt { position: fixed; left: 50%; bottom: 22px; z-index: 300; display: flex; flex-direction: column; gap: 12px; width: min(360px, calc(100% - 32px)); padding: 16px 18px; border-radius: 14px; background: linear-gradient(160deg,#241d13,#16130c); box-shadow: 0 20px 45px rgba(0,0,0,.45), inset 0 0 0 1px rgba(230,223,205,.12); opacity: 0; pointer-events: none; transform: translate(-50%,14px); transition: opacity .32s ease, transform .32s cubic-bezier(.2,.7,.2,1); }
.sound-prompt.is-visible { opacity: 1; pointer-events: auto; transform: translate(-50%,0); }
.sound-prompt-text { margin: 0; font: 12px/1.5 "Helvetica Neue",Helvetica,Arial,sans-serif; color: rgba(230,223,205,.85); }
.sound-prompt-actions { display: flex; gap: 10px; }
.sound-prompt-actions button { flex: 1; border: 0; border-radius: 8px; padding: 9px 12px; font: 700 10px/1 "Helvetica Neue",Helvetica,Arial,sans-serif; text-transform: uppercase; letter-spacing: .08em; cursor: pointer; transition: background .2s ease, transform .15s ease; }
.sound-prompt-actions button:hover { transform: translateY(-1px); }
.sound-prompt-yes { background: var(--amber); color: var(--ink); }
.sound-prompt-yes:hover { background: #cbb578; }
.sound-prompt-no { background: rgba(230,223,205,.08); color: rgba(230,223,205,.7); }
.sound-prompt-no:hover { background: rgba(230,223,205,.14); }
@media (max-width: 480px) {
  .sound-prompt-actions { flex-direction: column; }
}

/* One-time completion note from the Elder (showCompletionNoteOnce()
   in render-library.js) — shown once every translatable book has been
   fully decoded at least once, ever. Same fixed-bottom-card structure as
   .sound-prompt for visual consistency (one popup language on this
   page, not two), but its own class since the content and trigger are
   unrelated — a left amber accent border and italic Stronghold Print
   body copy set it apart as a "found note" rather than a UI prompt. */
.completion-note { position: fixed; left: 50%; bottom: 22px; z-index: 300; display: flex; flex-direction: column; gap: 10px; width: min(420px, calc(100% - 32px)); padding: 20px 22px; border-radius: 14px; border-left: 3px solid var(--amber); background: linear-gradient(160deg,#241d13,#16130c); box-shadow: 0 20px 45px rgba(0,0,0,.45), inset 0 0 0 1px rgba(230,223,205,.12); opacity: 0; pointer-events: none; transform: translate(-50%,14px); transition: opacity .32s ease, transform .32s cubic-bezier(.2,.7,.2,1); }
.completion-note.is-visible { opacity: 1; pointer-events: auto; transform: translate(-50%,0); }
.completion-note-text { margin: 0; font: italic 13px/1.6 "Stronghold Print","Menlo","Consolas",monospace; color: rgba(230,223,205,.9); }
.completion-note-sign { margin: 0; font: 10px "Stronghold Print","Menlo","Consolas",monospace; text-transform: uppercase; letter-spacing: .12em; color: rgba(230,223,205,.5); }
.completion-note-close { align-self: flex-end; border: 0; background: rgba(230,223,205,.08); color: rgba(230,223,205,.75); border-radius: 8px; padding: 7px 14px; font: 700 9px/1 "Helvetica Neue",Helvetica,Arial,sans-serif; text-transform: uppercase; letter-spacing: .08em; cursor: pointer; transition: background .2s ease; }
.completion-note-close:hover { background: rgba(230,223,205,.14); }

/* A rolled scroll, leaning against the books — the Elder's own present-
   day notes, visually distinct from a rectangular book spine at a glance.
   Uses the same --book-a/--book-b palette system as .book-spine (set in
   scrollButton() in render-library.js) for shelf color variety and to
   match its own modal cover when opened. */
/* A rolled scroll — the Elder's own present-day notes, visually
   distinct from a rectangular book spine at a glance. Round 44: swapped
   the hand-drawn inline SVG for a real image asset (scroll.png, a
   painted parchment-and-ribbon scroll on a transparent background,
   supplied by the user) — one flat raster layer instead of several
   gradient-filled paths, specifically to simplify the overlay-lighting
   math below (.shelf-scroll-sheen and the canvas glow's own
   clipToItemShape now both key off one clip shape, #scroll-clip in
   library/index.html, instead of each SVG-era overlay needing its own).
   The tilt read as "leaning against its neighbors" comes entirely from
   this element's own `transform: rotate(-4deg)` below, unchanged from
   before — the image itself is drawn straight-on, same as the old SVG's
   *positioning* always was (the old "leaning away" look was baked into
   its draw order, not a rotation; this element's rotation is the only
   tilt that ever mattered for how it sits on the shelf). */
/* No margin — book-to-book spacing comes entirely from .library-shelf's
   own `gap`, and a margin here (there used to be one, including a
   margin-bottom that lifted the scroll off the shelf ledge) both broke
   that alignment and made scroll-to-book spacing wider than book-to-book
   spacing. */
/* filter carries two stacked drop-shadows (round 30), same reasoning as
   .book-spine's 4th box-shadow layer above: a broad ambient one plus a
   tight "ground contact" one that fades toward transparent on hover,
   since a lifted scroll shouldn't still look like it's touching the
   shelf. filter (not box-shadow) because the visible shape is an alpha
   silhouette, not a rectangle — box-shadow follows the element's own
   box, drop-shadow follows the actual alpha shape, true of a raster PNG
   with transparency exactly the same way it was true of the old SVG. */
.shelf-scroll { position: relative; isolation: isolate; z-index: 1; flex: 0 0 auto; min-width: 0; align-self: flex-end; width: clamp(46px,4.4vw,62px); height: clamp(190px,22vw,270px); margin: 0; border: 0; padding: 0; background: transparent; cursor: pointer; transform: rotate(-4deg); transform-origin: bottom center; transition: transform .3s cubic-bezier(.2,.7,.2,1), filter .3s ease; filter: drop-shadow(0 10px 16px rgba(0,0,0,.35)) drop-shadow(0 3px 4px rgba(0,0,0,.5)); }
/* .is-opening — same reasoning as .book-spine.is-opening above, kept in
   sync so scrolls get the same pre-open visible beat books do. */
.shelf-scroll:hover, .shelf-scroll:focus-visible, .shelf-scroll.is-opening { transform: rotate(-4deg) translateY(-12px) scale(1.045); filter: drop-shadow(0 16px 22px rgba(0,0,0,.4)) drop-shadow(0 3px 4px rgba(0,0,0,.08)); }
/* scroll.png's own canvas is much wider than the tube it depicts (tall
   portrait image, tube centered with generous transparent margin on
   both sides), and far wider than this button's own narrow proportions
   — object-fit:cover scales the image up until its height fills this
   box, then center-crops the overflow width, which lands almost exactly
   on the tube itself (the image was framed with that in mind) without
   needing a separate cropped asset. draggable=false (set in
   render-library.js) plus this pointer-events:none keep the browser's
   native "drag this image out" affordance from ever competing with the
   button's own click handling. */
.shelf-scroll-img { display: block; width: 100%; height: 100%; object-fit: cover; object-position: center; pointer-events: none; }
/* The title only shows on hover/focus, not by default — the reference
   scroll has no visible label at rest, so this keeps that clean look
   while still surfacing the title for sighted mouse users (keyboard/
   screen-reader users get it from the button's aria-label regardless). */
.shelf-scroll-tag { position: absolute; top: 50%; left: 50%; transform: translate(-50%,-50%) rotate(-90deg); z-index: 2; white-space: nowrap; max-width: 120px; overflow: hidden; text-overflow: ellipsis; font: 9px "Stronghold Print","Menlo","Consolas",monospace; letter-spacing: .04em; color: #3a2a14; background: rgba(238,227,193,.92); padding: 2px 7px; border-radius: 2px; box-shadow: 0 2px 5px rgba(0,0,0,.28); pointer-events: none; opacity: 0; transition: opacity .2s ease; }
/* Round 53 follow-up — a permanently-visible label on a translated
   scroll (tried briefly this round) covered up the new wax-seal stamp
   on mobile, where the tag used to always show regardless of read state
   (round 23) — the tag sits dead-center, same spot the stamp needs, so
   "always on" and "stamp visible" couldn't coexist. Reverted per the
   user's explicit call: the tag should only ever appear on a deliberate
   hover/press, on both mobile and desktop, never permanently — :active
   covers "holding a thumb down" on touch the same way :hover/
   :focus-visible cover a mouse/keyboard user. */
.shelf-scroll:hover .shelf-scroll-tag, .shelf-scroll:focus-visible .shelf-scroll-tag, .shelf-scroll:active .shelf-scroll-tag { opacity: 1; }

/* No backdrop-filter here (unlike .privacy-overlay/.theater, which keep
   theirs) — this is a deliberate, scoped removal after finding it was
   almost certainly the real, still-remaining cause of the "flickers
   while translating/scrolling inside an open book" reports that
   persisted through several earlier rounds of fixes (pausing the shelf's
   own animations behind it, then removing content-visibility, then
   consolidating the scramble animation's rAF loops) — none of which
   fully resolved it, because none of them addressed backdrop-filter
   itself. backdrop-filter is a well-documented, expensive-on-mobile-GPU
   operation on its own, but the specific reason it kept showing up
   *here* and nowhere else on the site (contact/privacy modals use the
   same near-opaque-overlay-plus-blur pattern and were never reported as
   flickering) is that this is the one modal with content that keeps
   repainting for extended stretches while it's open — every glyph-word
   span rewritten frame-by-frame by the reveal engine during a translate
   click, for a paragraph, over roughly a 2-second window. Browsers
   commonly have to re-composite a backdrop-filter's sampled region
   whenever *anything* invalidates nearby paint, not only when the
   content actually behind it changes — so the scramble animation
   directly happening on top of this overlay is a very plausible trigger
   for continuous, expensive re-blurring for as long as any paragraph is
   mid-decode, exactly matching "flickers when translating" and "worst
   the whole time a book is open." The removal costs very little
   visually: this overlay's background is rgba(7,9,7,.9) — 90% opaque —
   so the shelf behind it was only ever ~10% visible before blurring it
   further softened that faint bleed-through. If this alone doesn't
   fully resolve it, the shelf-pausing and scramble-scheduler fixes below
   are still legitimate, real costs that were worth fixing regardless. */
.book-overlay { position: fixed; inset: 0; z-index: 340; display: grid; place-items: center; padding: 22px; background: rgba(7,9,7,.9); animation: theaterIn .3s ease both; }
/* Shelf animations are still paused for as long as a book is open, even
   without backdrop-filter above needing it: a fully-covered, invisible
   candle-flicker/dust-drift loop still costs real compositing work for
   zero visual benefit, so there's no reason to let it keep running.
   (The ambient glow's own flicker used to be a third selector here too —
   .shelf-lamp-ambient-glow::before — before that became a canvas draw.
   The continuous canvas flicker loop that briefly replaced it is gone
   entirely: candle glow is only redrawn in response to an actual change
   now, so there is no loop left running behind an open modal to pause.) */
body.book-modal-open .shelf-lamp-flame::before,
body.book-modal-open .dust-field {
  animation-play-state: paused;
}
/* The modal itself is the book's "cover" — a thin colored frame (tinted per
   book, via --book-a/--book-b copied from the spine that was clicked in
   render-library.js) around an inner cream "pages" panel below. Keeping the
   cover a fairly narrow border, with the pages panel doing most of the
   visual work, mirrors how little of a real book's binding shows once it's
   open on a table. */
/* will-change hints the browser to promote this to its own GPU layer
   before the animation's first frame, rather than deciding to do so
   only once the animation is already under way — helps avoid a stumble
   right at the start of the scale-up, when the layer is largest/most
   expensive to rasterize for the first time. */
/* max-height's reserved margin narrowed from 44px to 24px (round 31) —
   round 30's inner .book-pages fix (skip the tallest-page height lock for
   single-page books) was correct and is unrelated to this: it only
   controls the *inner* paginated-body container. This outer .book-modal
   has its own separate overflow-y:auto as a last-resort safety net for
   viewports too short to fit the whole card (title + byline + note +
   pages + hint, none of which are paginated away) — and per a follow-up
   screenshot, even a genuinely short single-page book could still nudge
   past the old, tighter 44px-reserved ceiling once you account for the
   title's own large clamp()'d font size (up to 46px, more with a long
   wrapping title) plus every fixed margin stacked above the body text.
   24px still leaves real breathing room from the viewport edge, just
   less of it reserved away from content that was already this close to
   needing it. */
/* clip-path (not just border-radius) on .book-modal/.book-modal--plain
   below — Chromium doesn't clip an element's own overflow-y:auto
   scrollbar to its border-radius, so a book long enough to need that
   scrollbar squared off the two corners on the scrollbar's side (right)
   while the other side stayed rounded. clip-path's rect is enforced at
   paint time regardless of the scrollbar, so repeating the same radius
   there forces those corners back to matching curves. */
.book-modal { position: relative; isolation: isolate; width: min(760px,100%); max-height: calc(100svh - 24px); overflow-x: hidden; overflow-y: auto; padding: clamp(14px,2.2vw,22px); border: 0; border-radius: clamp(18px,2.2vw,30px); clip-path: inset(0 round clamp(18px,2.2vw,30px)); background: linear-gradient(160deg,var(--book-a,#6d5a3a),var(--book-b,#2e2417)); box-shadow: 0 45px 110px rgba(0,0,0,.5), inset 0 0 0 1px rgba(255,255,255,.1); animation: bookOpen .5s cubic-bezier(.2,.74,.18,1) both; will-change: transform, opacity; }
/* .book-modal--plain — applied instead of the default book-a/b cover
   gradient whenever book.kind === "note" (finishOpenBook, render-
   library.js): a scroll is just parchment, with no bound "cover" to
   frame it the way a book has. Matching only the base rule's background
   (as this used to) still left the base .book-modal padding/border-
   radius/box-shadow in place — a visible cream ring plus its own drop
   shadow and a wider, differently-rounded outer corner peeking around
   .book-pages-inner's own smaller-radius corner, reading as a large
   outer border no scroll should have. Zeroed out here instead: no
   padding (pagesInner fills the modal exactly), no outer shadow
   (pagesInner already casts its own), and a matching border-radius so
   there's no double-rounded-corner seam. The background/transparent
   fallback is largely moot once padding is 0 (nothing of modal's own
   is left exposed around pagesInner to paint), but it's kept so
   rebuildPagesCutoutMask still has something coherent to reveal through
   its own corners are measured. */
.book-modal--plain {
  padding: 0;
  border-radius: clamp(10px,1.6vw,18px);
  /* Own smaller radius than the base .book-modal rule above, so its
     scrollbar-clipping clip-path (see that rule's comment) needs its own
     matching override here too, not just the inherited one. */
  clip-path: inset(0 round clamp(10px,1.6vw,18px));
  box-shadow: none;
  background: radial-gradient(130% 90% at 18% 0%, rgba(255,251,232,.55), transparent 55%), linear-gradient(155deg,#efe3bf 0%,#ddc998 55%,#c9b083 100%);
}
/* Round 52 — .content-fits (toggled by updateContentFit() in
   render-library.js, checked on every page show/turn and again once
   document.fonts.ready resolves) overrides the overflow-y:auto above to
   hidden whenever the card's actual content is measured to be shorter
   than max-height. overflow-y:auto alone used to leave two things
   possible on a book that visually looked like it fit the screen: (1) a
   thin scrollbar could still render even with nothing meaningful to
   scroll to, purely from sub-pixel layout rounding between scrollHeight
   and clientHeight; (2) touch/trackpad drag could still nudge the card a
   few px, which reads as "being allowed to scroll past the page" even
   though there's no real content past that point. Explicitly switching
   to hidden when content is confirmed to fit removes both, while a book
   that genuinely doesn't fit (short viewport, long content) keeps the
   real auto-scroll behavior untouched. */
.book-modal.content-fits { overflow-y: hidden; }
/* .modal-grain (below) is shared by every modal on the site, but only
   the book modal scales up dramatically (.2 -> 1) rather than just
   fading/nudging in — animating its own steps(2) grain flicker (a real
   repaint, not a compositor-only change) *while* the modal is also being
   rasterized at a rapidly changing scale compounded into exactly the
   partial-render/flicker reported on mobile. Pausing it only for the
   book modal, only while book-modal-open is set, removes that overlap;
   contact/privacy/entry-popup's grain is untouched since those don't
   have this scale-heavy transition. */
body.book-modal-open .book-modal .modal-grain { animation-play-state: paused; }
/* Mobile video-clip stutter fix. .modal-grain's mix-blend-mode:overlay
   has to be recomposited against whatever's underneath it on every
   frame — while the theater is open (openProjection/openVideoClip in
   app.js, both now toggle body.theater-open) that's a continuously
   playing video, not static content. Unlike the book-modal case just
   above, pausing the grain's own animation isn't enough here: even a
   perfectly static grain texture still forces a blend recompute against
   every changing video frame beneath it, so the ongoing cost is the
   blend itself, not the grain's own repaint. .film-grain sits behind
   the theater's own near-opaque backdrop (z-index 100 vs. the theater's
   200) so it's already invisible once the theater is open, but it still
   costs real compositing work every ~0.72s regardless of visibility —
   removed for the same reason. Neither one was adding anything a real
   video's own footage needed anyway. */
body.theater-open .modal-grain,
body.theater-open .film-grain {
  display: none;
}
/* The lighter end of some cover palettes (mustard, sage, amber) doesn't give
   the light "×" icon enough contrast on its own — a small dark scrim behind
   just the button keeps it readable against every cover color without
   touching .popup-close itself, which other modals on the site also use. */
/* z-index 3 so it stays above .book-pages-inner (z-index 2) — both are
   positioned siblings at the top of .book-modal, and .book-pages-inner
   comes later in DOM order, so without this the parchment panel's corner
   was painting over the × and hiding it entirely. */
/* Restyled (round 30) as a small wax-seal/stamp, in-theme with the
   parchment/leather book modal — scoped to .book-modal only, so the
   plain round scrim-backed × on the contact/privacy modals elsewhere on
   the site is untouched. Kept the same underlying <button>×</button>
   markup (render-library.js) — this is purely a CSS reskin, not a new
   element: irregular border-radius values (not a perfect circle) for a
   slightly organic, pressed-wax silhouette, a radial-gradient for the
   wax's own highlight/shadow, and inset shadows for a raised, slightly
   glossy surface. */
.book-modal .popup-close { z-index: 3; width: 34px; height: 34px; border-radius: 48% 52% 51% 49% / 53% 47% 55% 45%; background: radial-gradient(circle at 34% 30%, #8f3224 0%, #5c1810 62%, #3c0f09 100%); box-shadow: 0 3px 7px rgba(0,0,0,.5), inset 0 2px 3px rgba(255,180,140,.35), inset 0 -3px 5px rgba(0,0,0,.5); color: rgba(255,224,198,.88); font-size: 19px; text-shadow: 0 1px 0 rgba(0,0,0,.6), 0 -1px 0 rgba(255,180,140,.2); transition: background .2s ease, transform .2s ease, box-shadow .2s ease; }
/* Overrides the shared .popup-close:hover's rotate(4deg) with a slight
   press-in scale instead — reads like the seal being pressed rather than
   a generic hover wiggle, closer to the wax-stamp metaphor. */
.book-modal .popup-close:hover { background: radial-gradient(circle at 34% 30%, #a23c2c 0%, #6b1c12 62%, #451009 100%); transform: scale(.92); box-shadow: 0 2px 4px rgba(0,0,0,.5), inset 0 2px 3px rgba(255,180,140,.4), inset 0 -3px 5px rgba(0,0,0,.55); }
/* No filter step (there used to be a blur(6px) -> none sweep here) —
   animating filter alongside transform/opacity on an element this large
   (up to 760px wide) means the browser has to keep re-rendering a real
   blur every frame of the transition, on top of the transform itself;
   opacity/transform alone are compositor-only and stayed just as
   readable a "pulled off the shelf" motion without that extra cost. This
   was one of two stacked causes of the flickering reported specifically
   while this animation played — see .book-overlay below for the other. */
@keyframes bookOpen {
  from { opacity: 0; transform: translate(var(--book-origin-x,0px),var(--book-origin-y,0px)) scale(.2) rotate(-4deg); }
  to { opacity: 1; transform: translate(0,0) scale(1) rotate(0deg); }
}
/* Mobile-only alternative (round thirteen, see the @media block below
   where .book-modal's animation is swapped to this) — plain opacity
   fade, no transform at all. bookOpen above animates translate+scale+
   rotate together on an up-to-760px element for half a second; on a
   phone that's real, avoidable rasterization work for a flourish the
   user asked to cut. Desktop keeps bookOpen exactly as-is. */
@keyframes bookOpenMobile {
  from { opacity: 0; }
  to { opacity: 1; }
}
/* Reverse of bookOpen above — the book shrinking back toward the shelf
   spine/scroll it came from (--book-origin-x/y, --book-a/--book-b are
   already set on .book-modal from when it opened; closeBook() in
   render-library.js just adds .is-closing rather than removing/rebuilding
   the modal, so those custom properties are still in place and this
   animation lands on exactly the same origin point bookOpen started
   from). Two classes so this correctly outranks the plain .book-modal
   rule's own `animation: bookOpen ...` above regardless of source order. */
@keyframes bookClose {
  from { opacity: 1; transform: translate(0,0) scale(1) rotate(0deg); }
  to { opacity: 0; transform: translate(var(--book-origin-x,0px),var(--book-origin-y,0px)) scale(.2) rotate(-4deg); }
}
.book-modal.is-closing { animation: bookClose .38s cubic-bezier(.4,0,.74,.26) both; }
@keyframes bookCloseMobile {
  from { opacity: 1; }
  to { opacity: 0; }
}
@keyframes theaterOut {
  to { opacity: 0; }
}
.book-overlay.is-closing { animation: theaterOut .32s ease both; }
/* The pages panel: a neutral cream/parchment tone, consistent across every
   book (real paper doesn't change color book to book — only the cover
   does). All book-interior text lives inside this, so it switches from the
   rest of the site's light-on-dark palette to dark ink on light paper. */
/* overflow:hidden is a hard backstop, on top of the objectBoundingBox
   mask rework above (rebuildPagesCutoutMask, render-library.js): no
   matter what the mask is doing, nothing this element renders — its own
   background, or any descendant — can ever visually escape past its own
   current live box. Costs nothing (box-shadow, including the outer cast
   shadow, is unaffected by an element clipping its own overflow) and
   directly guarantees "nothing shows up past the edge," independent of
   any masking logic at all. */
.book-pages-inner { position: relative; z-index: 2; overflow: hidden; background: radial-gradient(130% 90% at 18% 0%, rgba(255,251,232,.55), transparent 55%), linear-gradient(155deg,#efe3bf 0%,#ddc998 55%,#c9b083 100%); border-radius: clamp(10px,1.6vw,18px); padding: clamp(24px,4.4vw,48px); box-shadow: inset 0 0 0 1px rgba(64,44,20,.28), inset 0 2px 12px rgba(255,255,255,.4), inset 0 -16px 28px rgba(64,44,20,.14), 0 16px 30px rgba(0,0,0,.35); }
/* padding-right reserves room for .popup-close (a 38px circle sitting at
   top:19px/right:22px on .book-modal itself, positioned independently
   of this flex row and its own padding) — .book-modal-topline's natural
   inset from the modal's true right edge is just .book-modal's own
   padding plus .book-pages-inner's, and at small/mobile widths (10px +
   18px = 28px) that's well inside the × button's own 22-60px footprint,
   so the right-aligned hint text ("Tap"/"Hover to reveal translation")
   was rendering underneath it. 44px is enough extra clearance at every
   breakpoint, not just mobile — the overlap risk existed at some
   in-between desktop widths too, not only on phones. */
.book-modal-topline { position: relative; z-index: 2; display: flex; align-items: center; justify-content: space-between; gap: 20px; padding-right: 44px; font: 9px "Stronghold Print","Menlo","Consolas",monospace; text-transform: uppercase; letter-spacing: .14em; color: rgba(64,44,20,.6); }
.book-title { position: relative; z-index: 2; margin: 22px 0 6px; font: 700 clamp(28px,4vw,46px)/1.05 "Stronghold Print","Iowan Old Style","Baskerville",serif; letter-spacing: 0; color: #2c1d0d; }
.book-byline { position: relative; z-index: 2; margin: 0 0 6px; font: 12px/1.5 "Stronghold Print","Menlo","Consolas",monospace; color: rgba(64,44,20,.7); }
.book-translator-note { position: relative; z-index: 2; margin: 0 0 30px; font: italic 11px/1.5 "Stronghold Print","Menlo","Consolas",monospace; color: rgba(64,44,20,.55); }
.lore-note { position: relative; z-index: 2; margin: 0 0 24px; font: italic 12px/1.5 "Stronghold Print","Menlo","Consolas",monospace; text-align: center; letter-spacing: .01em; color: rgba(64,44,20,.55); }
/* An "image" block (book-designer.html's own "+ Image" control) — a
   standalone block like .lore-note above it, not wrapped text: display:
   block plus margin auto/0 is what left/center/right-aligns it within
   the page's own text column without floating, so it never needs the
   reveal engine's box-lock math the way a .lore-paragraph's words do.
   width itself is set inline per block (buildLoreImage, render-library.js)
   as a percentage of that same column, so it scales with the column at
   any viewport rather than carrying a fixed px size. height: auto keeps
   the image's own aspect ratio regardless of what width lands on it.
   No border/background/shadow on purpose — a transparent-background
   illustration should sit directly on the page's own parchment texture,
   not float inside a second surface. */
.lore-image { position: relative; z-index: 2; display: block; height: auto; margin: 0 0 24px; }
.lore-image--center { margin-left: auto; margin-right: auto; }
.lore-image--left { margin-left: 0; margin-right: auto; }
.lore-image--right { margin-left: auto; margin-right: 0; }
/* A "space" block (book-designer.html's own "+ Space" control) — a pure
   vertical gap, height set inline per block. No margin of its own (unlike
   every other block type here): the author is already choosing the exact
   gap they want, so adding a base margin on top of that height would
   make the authored number a lie. */
.lore-space { width: 100%; }
.lore-paragraph { position: relative; z-index: 2; margin: 0 0 20px; font-size: 15px; line-height: 1.7; color: rgba(44,29,13,.88); cursor: help; transition: color .3s ease; }
.lore-paragraph:last-of-type { margin-bottom: 0; }
/* The decoded face is set here, not only on .glyph-word, because a finished
   paragraph no longer has any .glyph-word in it: flattenDecodedText
   (render-library.js) unwraps them once a reveal settles so the prose can be
   selected, copied and machine-translated as whole sentences. Without this
   the flattened text nodes inherited from .book-pages-inner, which sets no
   font-family, so a paragraph dropped to the page's fallback font the moment
   it finished translating — and only looked right again after closing and
   reopening the book, because the already-read path keeps its spans.
   Encoded and mid-scramble words still override this from .glyph-word. */
.lore-paragraph.decoded { cursor: text; font-family: "Stronghold Print","Menlo","Consolas",monospace; }
.lore-paragraph:not(.decoded):hover { color: #1c1206; }
/* A body block is now a <div class="lore-paragraph"> that can hold more
   than one line — .lore-line is one plain paragraph line inside it (a
   real <p>, so browser find-in-page/selection still treats each line as
   its own paragraph). Smaller bottom margin than the block-level 20px
   above, since these are lines *within* one passage, not separate
   passages — the block itself still keeps its own 20px gap from
   whatever comes after it. */
.lore-line { margin: 0 0 10px; }
.lore-line:last-child { margin-bottom: 0; }
/* Bullet/numbered/roman-numeral list groups — same font/color/cursor as
   the surrounding prose (inherited; nothing here needs to restate it),
   just real <ul>/<ol> markup so the browser draws the actual marker
   (disc/decimal/roman) instead of the author's typed "- "/"1."/"i."
   reaching the page as literal text — see parseBodyLines,
   render-library.js, which strips that marker text out for exactly this
   reason. padding-left leaves room for the marker without the list
   dragging the whole passage's left edge out of alignment with a plain
   paragraph line above/below it. */
.lore-list { margin: 0 0 10px; padding-left: 1.6em; }
.lore-list:last-child { margin-bottom: 0; }
.lore-list li { margin: 0 0 6px; }
.lore-list li:last-child { margin-bottom: 0; }
.lore-list--bullet { list-style-type: disc; }
.lore-list--numbered { list-style-type: decimal; }
.lore-list--roman-lower { list-style-type: lower-roman; }
.lore-list--roman-upper { list-style-type: upper-roman; }
/* "# "/"## "/"### " in a body block's raw text (parseBodyLines,
   render-library.js) — a standalone section heading, never merged with
   its neighbors the way list items are. Still a <p>, not a real <h#>:
   this is prose-scale structure inside one block, not part of the
   modal's own document outline (book title, shelf heading, etc.), so a
   plain visually-distinct paragraph is the more honest semantic than
   claiming a heading level that has nothing to do with the page's real
   one. Bold rather than italic — the opposite emphasis from body
   text — so a heading reads as structure at a glance, not just
   emphasized prose. Sizes are relative (em) to the block's own 15px, and
   still built through appendGlyphRuns like any other line, so a heading
   is exactly as encoded/decoded/redactable/linkable as the rest of the
   passage. */
.lore-heading { margin: 0 0 12px; font-weight: 700; }
.lore-heading--1 { font-size: 1.4em; }
.lore-heading--2 { font-size: 1.2em; }
.lore-heading--3 { font-size: 1.05em; text-transform: uppercase; letter-spacing: .04em; }
/* A list marker is generated by the browser and inherits from its <li>, not
   from the .glyph-word spans inside it — so the numerals and roman numerals
   were rendering in the page's fallback font next to correctly-set text. The
   marker is structure rather than the Elder's encoded prose, so it takes
   the decoded face at all times, including before the passage is revealed. */
.lore-list li::marker { font-family: "Stronghold Print","Menlo","Consolas",monospace; }
/* Emphasis markers in a body block (splitEmphasis, render-library.js)
   only ever wrap the decoded face in a real <strong> or <em>, and are
   deliberately never applied while a word is still encoded or mid
   scramble: those two states share one global width and scramble-pool
   measurement calibrated for a single font weight (see the note on
   ensureGlyphMetrics). This rule is what keeps that true regardless of
   DOM nesting, since a bold or italic ancestor would otherwise cascade
   into every descendant .glyph-word, including the encoded ones.

   NOTE: do not write the literal markdown markers for bold or italic in
   a CSS comment. Doubling an asterisk immediately before a slash closes
   the comment early — the original wording here did exactly that, which
   dropped every rule from .glyph-word onward and left body text, the
   hint, the pager and the side-nav buttons unstyled. */
.glyph-word:not(.decoded) { font-weight: normal; font-style: normal; }
/* inline-block, not inline, because the reveal engine (render-library.js)
   locks each word to an explicit width/height measured from its *decoded*
   text, its scramble mid-state and its encoded text, whichever is widest.
   Nothing is ever scaled, condensed or re-tracked: Keeper Script and
   Stronghold Print simply do not correspond per word (across this
   library's own text a word needs anywhere from 0.48x to 2.59x to match
   its counterpart), so every attempt to make them share a width squashed
   the glyph face. Reserving the widest state instead lets each one render
   exactly as drawn, and a narrower state just leaves a little space after
   its word. .glyph-released below drops the box again once a paragraph
   has finished decoding, so the finished prose gets its natural spacing
   back.
   A fixed box is what stops a font-family or textContent
   change inside one word from reflowing the paragraph, the page and the
   modal — which is what made a translate cost (words x frames) whole-
   paragraph layouts and left every measurement taken during one stale.
   Containment (contain: layout/size) would be the stricter guarantee but
   suppresses the element's baseline, which would break vertical
   alignment against the surrounding text; an explicit box gets the same
   isolation in practice while keeping normal inline-block baseline
   behaviour. vertical-align: top so a mid-decode font swap can't nudge
   the box via its contents' own baseline, white-space: nowrap so a
   rounding error can never wrap a word onto a second line inside its own
   box, and the .05em tracking below is only the pre-lock fallback — once
   locked, an inline width is what holds the geometry. */
.glyph-word { display: inline-block; vertical-align: top; white-space: nowrap; cursor: inherit; font-family: "Keeper Script","Menlo","Consolas",monospace; letter-spacing: .05em; color: #5c3a1e; transition: color .5s ease; }
/* Wraps a word run together with punctuation glued directly onto it (no
   space between them in the source, e.g. "it,") — see appendGlyphRuns in
   render-library.js for why this exists: two adjacent .glyph-word boxes
   with nothing between them can still be split across a line break by
   the browser, orphaning the punctuation alone at the start of the next
   line. inline-block here makes the pair one atomic box instead. */
.glyph-run-group { display: inline-block; vertical-align: top; }
/* The scramble mid-state renders in the *decoded* face, not a monospace
   one. It used to swap to Menlo, which is a third set of metrics again on
   top of the glyph face and the final face, and every word's box had to
   reserve room for whichever of the three was widest. Sharing the final
   face removes one of those. The colour shift is what marks a word as
   in-progress now, rather than the typeface change. See scrambleCharFor in
   render-library.js for how a proportional face is kept width-safe. */
.glyph-word.decoding { font-family: "Stronghold Print","Menlo","Consolas",monospace; letter-spacing: normal; color: #8a4a1e; }
.glyph-word.decoded { font-family: "Stronghold Print","Menlo","Consolas",monospace; letter-spacing: normal; color: rgba(44,29,13,.88); }
/* The reserved box is dropped once every word in a paragraph has settled
   (releaseGlyphBoxes, render-library.js), during a short opacity dip that
   hides the re-wrap. It restores the things the box overrode — auto width comes from clearing
   the inline style, and baseline alignment and normal wrapping come from
   here — so the finished prose gets its true spacing back.

   .glyph-word stays inline-block rather than reverting to inline, so that
   the released state differs from the boxed one by as little as possible —
   only the reserved width, the alignment and the wrapping. The one thing
   inline-block costs is that an ancestor's text-decoration is not drawn
   through it, which the .glyph-link-group rule below restores. */
.lore-paragraph.glyph-released .glyph-word { vertical-align: baseline; white-space: normal; }
/* Same alignment swap as .glyph-word above, kept in step so a grouped
   word+punct pair (see .glyph-run-group) sits on the line identically to
   every standalone .glyph-word once released — only its own children's
   spacing needs to stay atomic, not its vertical position. */
.lore-paragraph.glyph-released .glyph-run-group { vertical-align: baseline; }
/* A period-substitute placeholder is drawn in the *decoded* face, not the
   glyph face. SGA's period is a large decorative rune — 13px against the
   decoded period's 3px at body size — and every character outside
   [a-zA-Z.] is period-substituted while encoded, which includes every
   accented Latin letter and every Cyrillic one. Measured in SGA those
   placeholders reserved a word box far wider than the decoded text needs
   (29px of dead space on "Съешь", 40px on "мягких"), and that surplus
   showed as gaps punched through translated and accented text. Rendering
   them in the decoded face makes the placeholder narrower than the text it
   stands in for, so it can never drive the box. Tracking goes with it — the
   .16em was chosen to space SGA runes apart. */
.glyph-punct:not(.decoded) { font-family: "Stronghold Print","Menlo","Consolas",monospace; letter-spacing: normal; }
/* .glyph-rune — a run of text wrapped in backticks in a body block's raw
   text (`word`), for quoting the constructed script itself inline in
   otherwise-translated prose rather than a passage waiting to be
   translated. Deliberately not .glyph-word: it never joins the reveal
   job, the box-lock pass or flattenDecodedText (all three key off the
   .glyph-word class alone — see appendRuneToken/splitRunicMarks in
   render-library.js), so it can't be scrambled, resized or unwrapped; it
   just always renders in the same look every other word has before
   translation, permanently. font-weight/font-style are reset the same
   way .glyph-word:not(.decoded) resets them above — Keeper Script has no
   matching bold/italic cut, so a bold- or italic-marked wrapped rune
   (splitEmphasis still runs on the same text) shouldn't fake one.
   font-size is bumped relative to the surrounding prose — at the body's
   own 15px, Keeper Script's finer strokes read noticeably smaller than the
   Stronghold Print text around a quoted rune; 1.3em restores a closer
   visual weight. Safe to size independently here (unlike .glyph-word):
   .glyph-rune carries no reserved box, so nothing measures or locks a
   width against this font-size the way lockGlyphBoxes does for
   .glyph-word. letter-spacing is already em-based, so it scales with it
   automatically.
   line-height: 1 overrides .lore-paragraph's own line-height: 1.7 —
   deliberately, since that's a *unitless* value, which a descendant
   recomputes against its own font-size rather than inheriting a fixed
   px number. At this element's bumped 1.3em font-size that recomputed
   to ~30% more leading than every plain-text line around it, and it's
   that extra leading — not the taller glyph itself — that was shoving
   the line above upward. line-height: 1 leaves this box only as tall as
   Keeper Script's own ascent+descent at its bumped size, which is
   already enough to grow the line slightly for the larger glyph without
   adding leading on top of it. */
.glyph-rune { font-family: "Keeper Script","Menlo","Consolas",monospace; font-size: 1.3em; line-height: 1; letter-spacing: .05em; color: #5c3a1e; font-weight: normal; font-style: normal; }
/* .glyph-redacted — a permanently illegible passage (authored as
   "[. . .]" in library.json; splitRedactions in render-library.js pulls
   it out before word tokenization). Unlike a .glyph-word, there's no
   real value underneath this to decode, so instead of literal bracket-
   and-dot characters sitting in otherwise-legible prose, it renders as a
   patch of blurred-together ink where a word's worth of SGA runes used
   to be legible — smudged past reading, not blacked out.
   Round two: the first pass (one flat radial-gradient blob, solid
   opacity, an inset shadow, vertical-align:middle floating it above the
   baseline) read as a UI element — a clean gray pill sitting on the
   page rather than damage to the page itself. This version instead
   layers several small, irregularly-placed dark patches (echoing
   individual smeared letter-strokes rather than one shape), sits on the
   text baseline at roughly x-height like real lowercase glyphs would,
   and uses mix-blend-mode:multiply plus a real blur so it darkens
   *into* the page's own paper gradient instead of painting a flat color
   over it — the same rgba(64,44,20,…) ink tone the page's own text
   uses, just diffused. --dots (set inline, one per period in the source
   markup) drives width and a slight per-instance rotation.
   Round three — background is now generated procedurally in JS
   (buildSmudgeBackground, render-library.js) and set inline per mark,
   replacing the four fixed [data-smudge="0..3"] recipes round two used.
   Those had a real width formula scaling with --dots but a *fixed* blob
   count, so a long run of dots (e.g. ten) just spread the same 4-5 blobs
   further apart with more empty gap between them rather than reading as
   a longer smear — buildSmudgeBackground scales blob count with dotCount
   instead, deterministically seeded the same way the mark's own shape
   choice is, so the same passage always regenerates the same-looking
   smudge.
   --smudge-h/--smudge-shift (still set inline, different-length cycles
   on purpose) vary height and vertical position the same way
   TEAR_HEIGHTS/TEAR_SHIFTS do, so a row of these doesn't read as one
   stain resized. */
.glyph-redacted {
  display: inline-block;
  vertical-align: baseline;
  width: calc(var(--dots, 3) * .62em + .4em);
  height: var(--smudge-h, .6em);
  margin: 0 .16em;
  border-radius: 40%;
  mix-blend-mode: multiply;
  filter: blur(1.4px);
  opacity: .6;
  transform: translateY(var(--smudge-shift, 0px)) rotate(calc(var(--dots, 3) * .5deg - 1deg));
}
/* A permanently-scrambled placeholder word (built by buildTeaserWord,
   re-scrambled forever by wireEternalScramble in render-library.js) —
   currently only the next-drop teaser's undecided title letters.
   Deliberately separate from .glyph-word above rather than reusing it:
   .glyph-word is built for the book-modal's cream parchment (its own
   hardcoded ink colors) and for the finite decode-then-stop mechanic (the
   reveal engine, hover-triggered, resolves to real text and remains).
   This is the opposite on every axis — sits on the page's own dark
   background using whatever color the element already has (only
   font-family/letter-spacing are overridden here), runs forever via a
   separate always-on interval, and has no hover/cursor affordance since
   there's nothing to interact with. */
.eternal-glyph { font-family: "Keeper Script","Menlo","Consolas",monospace; letter-spacing: .05em; }
/* One fixed-width inline-block per *letter* inside an .eternal-glyph span
   (built once in buildTeaserWord, never rebuilt — each scramble tick only
   ever mutates each span's own textContent). Needed because "Keeper
   Script" isn't a monospace font: swapping which random letter lands in a
   given position used to change that letter's own rendered advance width,
   which could shift a whole word's total pixel width enough to cross a
   line-wrap threshold. Pinning every letter to the same width (regardless
   of which glyph it ends up drawing) makes total word/line width constant
   across ticks, so wrapping only ever changes with the viewport, never
   with the RNG. Real spaces are left as plain text nodes between these
   spans so normal word-boundary wrapping still applies. */
.glyph-char { display: inline-block; width: .62em; text-align: center; }
/* Standard screen-reader-only utility — visually removed but still in the
   accessibility tree. */
.visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
/* "Next translation" teaser panel (#next-drop-panel, library/index.html;
   built by wireNextDrop() and friends in render-library.js) — only ever
   shows the pre-reveal placeholder now; a revealed book becomes a real
   shelf item instead (see /api/library, init() in render-library.js), so
   there's no "revealed state" styling to carry here anymore. Right-
   aligned on purpose, per feedback — keeps it well clear of a hidden
   footer element, which sits low and to the left. */
.next-drop-panel { margin: 18px 0 0; display: flex; flex-direction: column; align-items: flex-end; gap: 6px; }
.next-drop-label { margin: 0; font: 9px monospace; text-transform: uppercase; letter-spacing: .15em; color: rgba(230,223,205,.42); }
/* Placeholder .eternal-glyph words (built by buildTeaserWord, scrambled
   forever by wireEternalScramble) standing in for a title nobody,
   including this stylesheet, actually knows yet. */
.next-drop-title { margin: 0; font-size: 18px; text-align: right; }
.next-drop-word { font-size: 18px; }
/* A letter the Elder's actually found (buildTeaserWord/
   wireEternalScramble's own :not(.confirmed) guard, render-library.js) —
   warm amber against the rest of the word's still-scrambling runes, so
   the handful of found letters read as small, deliberate discoveries
   rather than getting lost in the noise. transition catches the moment
   a slot flips from scrambling to confirmed (only ever checked on
   reload/refetch, not live mid-scramble, but still a nice settle if it
   happens to land right as the panel re-renders). */
/* Swaps out of the parent .eternal-glyph's Keeper Script cipher face
   for a found letter — same font-family/letter-spacing .glyph-word.decoded
   uses for real decoded book text, so a confirmed letter actually reads
   as "found," not just recolored cipher script. Kept in the fixed .62em
   .glyph-char box regardless of font (needed so the row's total width
   stays stable as letters flip between faces) — Stronghold Print's own
   natural glyph widths don't matter here since nothing in this box is
   proportionally sized. */
.next-drop-word .glyph-char.confirmed { font-family: "Stronghold Print","Menlo","Consolas",monospace; letter-spacing: normal; color: var(--amber); text-shadow: 0 0 6px rgba(185,163,101,.5); transition: color .4s ease; }
.next-drop-when { margin: 0; max-width: 46ch; font: 9px monospace; text-transform: uppercase; letter-spacing: .1em; color: rgba(230,223,205,.32); text-align: right; }
/* In-text cross-references between books ([[slug|text]] in library.json
   body text, parsed by splitCrossLinks/wireCrossLink in
   render-library.js). The whole linked phrase — every word plus the
   whitespace between them — lives inside one .glyph-link-group wrapper,
   so the underline/glow/click-target reads and behaves as a single
   continuous block rather than each word being its own separately
   underlined, separately hoverable target. Deliberately *not* colored
   like a URL — it should still read as part of the translated passage,
   not as a foreign "web link" element — so this only adds an underline,
   plus a soft amber glow on hover/focus to signal it's followable. Only
   ever applied once .decoded is also present on the group, i.e. only
   after every word inside it has finished decoding — before that it's
   visually identical to the rest of the still-encoded/decoding text,
   since the reference isn't meant to be followable (or noticeable)
   until the Elder has translated all of it. */
.glyph-link-group.decoded { text-decoration: underline; text-decoration-color: rgba(64,44,20,.4); text-underline-offset: 2px; cursor: pointer; }
/* The group's own underline covers the whitespace between its words, but
   each .glyph-word is an atomic inline-level box (see its own rule above)
   and so is skipped by that decoration — this draws the same line under
   the words themselves, matched exactly so the two read as one
   continuous underline rather than a dashed one. */
.glyph-link-group.decoded .glyph-word { text-decoration: underline; text-decoration-color: rgba(64,44,20,.4); text-underline-offset: 2px; }
.glyph-link-group.decoded:hover, .glyph-link-group.decoded:focus-visible { text-shadow: 0 0 7px rgba(185,163,101,.85); outline: none; }
.library-hint { position: relative; z-index: 2; margin: 26px 0 0; padding-top: 16px; border-top: 1px solid rgba(64,44,20,.18); font: 11px "Stronghold Print","Menlo","Consolas",monospace; text-transform: uppercase; letter-spacing: .14em; color: rgba(64,44,20,.5); text-align: center; }
/* Parchment-themed scrollbar override for the reading surface — .book-pages
   (the paginated body text) and .book-modal (its own overflow-y:auto
   "doesn't fit the viewport" fallback, see that rule's comment above) both
   sit on cream/parchment (.book-pages-inner, .book-modal--plain), where the
   site-wide amber-on-transparent thumb above would read as too light to
   see and thematically wrong — an inked, dark-brown thumb reads as "quill
   ink on the page" instead, matching .book-title/.book-byline's own
   rgba(64,44,20,*) ink color. Covers scrolls (kind: note, .book-modal--plain)
   too since they share this same .book-pages/.book-pages-inner markup —
   there's no separate note-only scroll container to theme. */
.book-modal, .book-pages { scrollbar-color: rgba(64,44,20,.4) transparent; }
.book-modal::-webkit-scrollbar-thumb, .book-pages::-webkit-scrollbar-thumb { background-color: rgba(64,44,20,.35); }
.book-modal::-webkit-scrollbar-thumb:hover, .book-pages::-webkit-scrollbar-thumb:hover { background-color: rgba(64,44,20,.6); }
.book-pages { position: relative; z-index: 2; overflow-x: hidden; overflow-y: auto; }
.book-page { display: none; }
.book-page.active { display: block; animation: pageTurnIn .4s cubic-bezier(.2,.72,.2,1) both; }
.book-page.active.enter-right { animation-name: pageTurnInRight; }
.book-page.active.enter-left { animation-name: pageTurnInLeft; }
@keyframes pageTurnIn { from { opacity: 0; } }
@keyframes pageTurnInRight { from { opacity: 0; transform: translateX(30px); } to { opacity: 1; transform: translateX(0); } }
@keyframes pageTurnInLeft { from { opacity: 0; transform: translateX(-30px); } to { opacity: 1; transform: translateX(0); } }
.book-pager { position: relative; z-index: 2; display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-top: 6px; padding-top: 14px; border-top: 1px solid rgba(64,44,20,.18); }
.book-pager button { border: 0; background: transparent; color: rgba(64,44,20,.68); text-transform: uppercase; letter-spacing: .12em; font: 11px "Stronghold Print","Menlo","Consolas",monospace; cursor: pointer; padding: 6px 4px; transition: color .2s ease; }
.book-pager button:hover:not(:disabled) { color: #7a4318; }
.book-pager button:disabled { opacity: .32; cursor: default; }
.book-pager-label { font: 11px "Stronghold Print","Menlo","Consolas",monospace; text-transform: uppercase; letter-spacing: .14em; color: rgba(64,44,20,.5); }
/* Desktop-only page-turn arrows sitting outside the modal itself (round
   33), per the user's own diagram — appended as direct children of
   .book-overlay in render-library.js (not .book-modal, whose own
   overflow-x:hidden would clip anything positioned past its edges), so
   these can sit in the dark margin on either side of the book at real
   desktop widths. Mobile has no room for them alongside the modal's own
   near-full-width footprint, and already has the bottom Prev/Next
   buttons within easy thumb reach — hidden entirely below 820px rather
   than just shrunk. */
.book-side-nav-btn { position: absolute; top: 50%; z-index: 2; transform: translateY(-50%); width: 52px; height: 52px; display: grid; place-items: center; border: 0; border-radius: 50%; background: rgba(230,223,205,.08); color: rgba(230,223,205,.6); cursor: pointer; transition: background .2s ease, color .2s ease, opacity .2s ease; }
.book-side-nav-btn:hover:not(:disabled), .book-side-nav-btn:focus-visible:not(:disabled) { background: rgba(230,223,205,.16); color: rgba(230,223,205,.92); }
.book-side-nav-btn:disabled { opacity: .28; cursor: default; }
.book-side-nav-prev { left: clamp(10px,4vw,64px); }
.book-side-nav-next { right: clamp(10px,4vw,64px); }
/* "Skip to next/previous book" — a different action from the page-turn
   arrows above (turns pages *within* the open book), so it needs to
   read as visually distinct: same left/right side (same x position) as
   its page-turn counterpart, but stacked further out — below it,
   smaller, dimmer at rest — with a double-chevron icon instead of a
   single one. Hidden (not just disabled) at either end of the shelf's
   own book order, same pattern as .book-side-nav-btn's :disabled. */
.book-skip-nav-btn { position: absolute; top: calc(50% + 74px); z-index: 2; transform: translateY(-50%); width: 40px; height: 40px; display: grid; place-items: center; border: 0; border-radius: 50%; background: rgba(230,223,205,.05); color: rgba(230,223,205,.45); cursor: pointer; transition: background .2s ease, color .2s ease, opacity .2s ease; }
.book-skip-nav-btn:hover:not(:disabled), .book-skip-nav-btn:focus-visible:not(:disabled) { background: rgba(230,223,205,.13); color: rgba(230,223,205,.85); }
.book-skip-nav-btn:disabled { opacity: 0; pointer-events: none; }
.book-skip-nav-prev { left: clamp(10px,4vw,64px); }
.book-skip-nav-next { right: clamp(10px,4vw,64px); }
@media (max-width: 820px) {
  .book-side-nav-btn, .book-skip-nav-btn { display: none; }
}
.book-modal .book-episode-link { position: relative; z-index: 2; margin: 20px 0 0; width: max-content; color: rgba(64,44,20,.62); }
.book-modal .book-episode-link:hover { color: #7a4318; }

@media (max-width: 820px) {
  .book-spine { width: clamp(48px,10vw,64px); height: clamp(160px,42vw,220px); }
  .book-spine.book-spine--short { height: clamp(120px,32vw,170px); }
  .book-spine.book-spine--tall { height: clamp(190px,50vw,260px); }
  .book-spine.book-spine--narrow { width: clamp(40px,8vw,52px); }
  .book-spine.book-spine--wide { width: clamp(56px,12vw,72px); }
  .library-shelf-empty-slot { width: clamp(48px,10vw,64px); height: clamp(160px,42vw,220px); }
  /* Smaller, tighter-tracked than the desktop clamp(13px,1.3vw,17px)/
     .01em default — per direct feedback that long titles were ellipsing
     off too many mobile spines. This buys real room (roughly 2-3 more
     characters before truncating, depending on the title's own glyph
     widths) but has a real legibility floor of its own; the longest
     titles will still truncate at this size — see the spineLabel content
     itself (data/library.json) for the other half of this fix. */
  .book-spine-label { font-size: clamp(11px,2.6vw,14px); letter-spacing: 0; }
  .book-modal { padding: 10px; }
  /* Same-specificity override, same reasoning as the desktop rule above
     (.book-modal--plain, near .book-modal's own definition) — without
     this, this media query's own .book-modal padding (textually later
     in the file) would win the cascade back and reintroduce the outer
     ring on mobile notes specifically. */
  .book-modal--plain { padding: 0; }
  .book-pages-inner { padding: 22px 18px; }
  .book-title { font-size: clamp(26px,7vw,38px); }

  /* Round thirteen — phones get a heavily reduced-motion version of the
     shelf's atmosphere. Per the user: mobile should cut out high-intensity,
     continuously-repainting effects wherever the visual payoff isn't
     worth the ongoing cost, even when each one in isolation already
     passed its own perf audit — several small continuous repaints
     stacking up is exactly the kind of thing that reads as "flashy" in
     aggregate on a phone GPU even if no single one of them is the sole
     cause. Desktop is untouched by everything in this block. */
  /* Restored. The flicker was re-enabled on mobile for a stretch so the
     canvas glow system could be stress-tested with it running; the
     ?perfdebug sessions that came out of that showed jank frames at idle
     with nothing being interacted with, which is exactly what a permanent
     per-flame animation costs on a phone. Snuffing and relighting still
     fade — that lives on .shelf-lamp-flame's own opacity transition, not
     on this animation (see the .is-unlit rules above), so turning the
     flicker off here does not flatten the tap. */
  .shelf-lamp-flame { opacity: 1; transform: translateX(-50%); }
  .shelf-lamp-flame::before { animation: none; }
  /* Restored, same reasoning as the flame flicker above. Dust motes were
     re-enabled for a while to see whether they held up once candle glow
     moved to canvas; they are another permanently-animating layer whose
     cost is paid whether or not anyone is looking at it, and the idle jank
     in those sessions says the answer was no. */
  .dust-field { display: none; }
  /* Candles used to be drawn with two mobile-only <canvas> elements here
     (see .library-glow-canvas below, and wireGlowActivation/drawGlow in
     render-library.js) while desktop kept the original DOM system
     (.shelf-lamp-ambient-glow/.shelf-item-candlelight). Both platforms
     now share that one canvas-based system for every screen size — see
     .library-glow-canvas's own comment for the full retirement note —
     so there's nothing candle-specific left to carve out in this
     mobile-only block. */
  /* .film-grain predates this block and was never folded into it — same
     "continuous repaints stacking up reads as flashy in aggregate on a
     phone GPU" reasoning applies to it even more than to the candles
     above: it's the single largest continuously-animating layer on the
     page (full viewport, mix-blend-mode, a transform+scale step every
     .36s, forever), running unconditionally on every page whether or not
     anyone's even looking at the library shelf.
     Round 63 — freezing the animation (the old approach, see the removed
     comment history above) turned out not to be enough on its own: a
     static grain texture still carries a real, ongoing mix-blend-mode
     cost every time *anything* underneath it repaints, exactly the same
     "has to be recomposited against whatever's changing beneath it"
     problem just fixed for .modal-grain during video playback above —
     and the library page already has plenty else moving under it (the
     shelf's own dust motes, candle flicker, the locked-state letter
     scramble). Removed outright instead, scoped to just this page —
     .film-grain runs sitewide, so this selector is deliberately
     .subpage-shell.library-page .film-grain, not the bare class, to
     avoid touching it anywhere else. Does NOT touch the separate,
     unrelated prefers-reduced-motion rule further down (same selector,
     different block) — that one stays untouched regardless, since it's a
     real accessibility preference, not a performance optimization. */
  .subpage-shell.library-page .film-grain { display: none; }
  /* Simple fade instead of the translate+scale+rotate "pulled off the
     shelf" motion — see @keyframes bookOpenMobile above. */
  .book-modal { animation: bookOpenMobile .35s ease both; }
  /* Same reduced-motion reasoning as bookOpenMobile above, applied to
     closing — plain fade rather than the shrink-back-to-the-shelf motion
     desktop gets. Two classes (.book-modal.is-closing) so this still
     outranks the single-class .book-modal rule right above it despite
     matching the same element. */
  .book-modal.is-closing { animation: bookCloseMobile .25s ease both; }

  /* Round 23 originally made this always-visible on mobile (no real
     hover state to reveal it otherwise). Round 53 reverted that: it's
     the same fixed dead-center spot the wax-seal read-stamp now uses
     (see .shelf-scroll.is-read::after), and a permanently-shown tag
     covered the stamp up entirely on a translated scroll. Touch devices
     now rely on the shared :active rule above (styles.css, near
     .shelf-scroll-tag's base rule) — press and hold to see the label,
     same "deliberate reveal" gesture desktop hover already was. */
}

@media (prefers-reduced-motion: reduce) {
  .book-spine { transition: none !important; }
  .sound-prompt { transition: none !important; }
  .completion-note { transition: none !important; }
  html { scroll-behavior: auto; }
  *, *::before, *::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; }
  .film-grain { animation: none !important; transform: none; }
  .hero-video { display: none; }
}
