/* ============================================================
   The Altshuller Year 2026 — Biography mode
   Design canvas: 1920 × 1080. Fluid scale: 1rem = 100px @1920.
   ============================================================ */

/* №71: Red Hat Mono (OFL) replaced the ABC Diatype Mono TRIAL — the
   trial licence forbids production, and the contest jury would notice */
@font-face {
  font-family: "Red Hat Mono";
  src: url("fonts/RedHatMono-Regular.ttf") format("truetype");
  font-weight: 400;
  font-style: normal;
  font-display: block;
}

@font-face {
  font-family: "Anton";
  src: url("fonts/Anton-Regular.ttf") format("truetype");
  font-weight: 400;
  font-style: normal;
  font-display: block;
}

:root {
  --red: #ee0b0b; /* matched to the real 100-GSA logo red */
  --ink: #525252;
  --ink-40: rgba(82, 82, 82, 0.4);
  --chip: #eeeeee;
  --chip-line: #e3e3e5; /* №77: ~11% from white — the old #f4f4f5 (4%)
                            vanished on ordinary laptop screens */
  --bg: #ffffff;
}

* { margin: 0; padding: 0; box-sizing: border-box; }

html {
  font-size: calc(100vw / 19.2);
  overflow-x: clip;
}
/* on ultra-wide screens the layout stops growing and centers */
@media (min-width: 2560px) {
  html { font-size: 133.34px; }
}

body {
  background: var(--bg);
  color: var(--ink);
  font-family: "Red Hat Mono", ui-monospace, "Courier New", monospace;
  font-weight: 400;
  -webkit-font-smoothing: antialiased;
  overflow-x: clip;
}

::selection { background: var(--red); color: #fff; }

/* centering wrapper for fixed layers (ultra-wide cap) */
.canvas {
  position: relative;
  width: 19.2rem;
  height: 100%;
  margin: 0 auto;
}

/* ---------- fixed hero image (intro) ---------- */

.hero {
  position: fixed;
  inset: 0;
  z-index: -1; /* below in-flow content, above body background */
  pointer-events: none;
}
/* №64: the real portrait (Anton's sketch-lines PNG, alpha, cropped to
   1344×1220). Screen-centred, FIXED aspect (never stretched top-to-
   bottom): height = width × 1220/1344. The headline runs over the top
   of the head, the prompt + buttons sit on the chest (per the mock). */
.hero__img {
  position: absolute;
  /* №65: the EYES (54% of the image) sit mid-way between the red block
     and the buttons, the nose never reaches the prompt (6rem).
     №73 (препод): the base grew 7 → 8rem AND the portrait breathes with
     the scroll — render() scales it 1.2 → 0.92 toward its centre as
     the intro dissolves (depth effect, all tiers; keep the JS numbers
     in sync). At rest it reads much bigger (8 × 1.2 ≈ 9.6rem). */
  left: 5.6rem; /* (19.2 − 8) / 2 — screen centre */
  top: 0.5rem;
  width: 8rem;
  height: 7.26rem; /* 8 × 1220/1344 */
  background: url("assets/altshuller.png") no-repeat top center / contain;
  opacity: 0.85; /* sync with the 0.85 base in render() */
}

/* ---------- fixed left zone: connected event ---------- */

.aside {
  position: fixed;
  inset: 0;
  z-index: 2;              /* layer 2 — with the feed content */
  pointer-events: none;
}
.aside__box {
  position: absolute;
  left: 0.2rem;
  top: calc(50vh - 0.18rem);
  width: 7.05rem;
}
.aside__title {
  font-size: 0.2rem;
  line-height: 0.28rem;
}
.aside__meta {
  margin-top: 0.16rem;
  font-size: 0.16rem;
  line-height: 0.224rem;
  text-transform: uppercase;
  color: var(--ink-40); /* same muted grey as the episode meta rows */
}


/* line-by-line masked swap: each rendered line clips its own strip.
   Old lines sink DOWN into the masks, new ones rise UP from below,
   slightly blurred while moving — a soft catching-up cascade */
.line { overflow: hidden; }
.line__inner {
  display: block;
  white-space: nowrap;
  transform: translateY(108%);
  opacity: 0;
  transition-property: transform, opacity;
  transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
.aside__box.is-in .line__inner {
  transform: translateY(0);
  opacity: 1;
}
.aside__box.is-out .line__inner {
  transform: translateY(108%);
  opacity: 0;
  transition-duration: 300ms !important;
  transition-delay: 0ms !important;
  transition-timing-function: cubic-bezier(0.55, 0, 0.55, 0.2) !important;
}

/* ---------- edge masks ---------- */

.mask {
  position: fixed;
  left: 0;
  right: 0;
  z-index: 3;
  pointer-events: none;
  opacity: 0;
}
/* top gradient removed (Anton №1): the reading area is taller now */
.mask--bottom {
  bottom: 0;
  height: 1.28rem;
}
/* №78: the bottom panel is TWO gradient layers — the full-height one and
   the short calendar strip (adaptive only) — and the calendar swap is an
   OPACITY fade of the full layer, not a height tween: the old height
   transition re-rasterised the fixed gradient every frame and read as a
   snap/stutter on the phone; opacity is compositor-only, so it glides.
   The strip lives inside the full layer's solid band, so keeping it
   always on changes nothing at rest — no white ever dips mid-fade.
   (The PARENT's opacity stays render's per-frame channel, leave it.) */
.mask__full,
.mask__cal {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  background: linear-gradient(to top, var(--bg), rgba(255, 255, 255, 0));
}
.mask__full {
  height: 100%;
  transition: opacity 780ms cubic-bezier(0.22, 1, 0.36, 1);
}
.mask__cal { display: none; height: 1.2rem; }
/* ADAPTIVE top zone (desktop never shows it): white runs down from the
   top edge; the control position and the pinned episode head sit ON it,
   the passing feed fades away UNDER it */
.mask--top {
  display: none;
  top: 0;
  background: linear-gradient(
    to bottom,
    var(--bg) 0%,
    var(--bg) 66%,
    rgba(255, 255, 255, 0) 100%
  );
}

/* adaptive, calendar open: the red bottom block leaves LINE BY LINE — a
   pyramid crumbling from its foundation (per-word transforms, JS owns the
   stagger in calCollapse); the top control positions dim; the cal button
   shrinks to the mode-chips' 32px */
@media (max-width: 1100px) {
  /* the feed text passes UNDER the red block (Anton: no layered pie —
     красный целиком над текстом); still under the buttons (5) */
  body.mode-chosen .chrome { z-index: 3; }
  /* №52→№54: the start got twice as short (early commit) — the first
     episode arrives right behind the commit, no empty scrolling. (The
     .intro shrink lives in the LAST block — the base rule is below.) */
  .intro-rest { height: 4vh; }
  body.cal-open .pinhead { opacity: 0.2; }
  /* calendar: the red block is gone, so its tall panel gives way to a
     TINY white strip under the chips/links (№48) — the events and dates
     fly out from beneath its short gradient edge, mirroring the top zone.
     №78: the tall layer FADES out over the strip (no height tween) */
  .mask__cal { display: block; }
  body.cal-open .mask__full { opacity: 0; }
  .cal-btn {
    width: 0.32rem;
    height: 0.32rem;
  }
  .cal-btn .cal-btn__icon {
    width: 0.14rem;
    height: 0.14rem;
  }
}

/* adaptive: the pinned head of the current episode — the second control
   position (year + title), swapped by the same control line as the aside */
.pinhead {
  display: none;
  position: fixed;
  z-index: 4;
  pointer-events: none;
  opacity: 0;
  transform: translateX(-50%) translateY(0.12rem);
  transition: opacity 0.5s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
.pinhead.is-in {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}
.pinhead__year {
  text-transform: uppercase;
  color: var(--ink-40);
}
.pinhead__title { font-weight: 400; }

/* ---------- layered chrome ----------
   Stack (bottom → top):
     layer 1  .chrome  (big red headline)   z-index 1
     layer 2  .page / .aside (feed content)  z-index 2  — overlaps headline
     layer 3  .toggle (mode buttons)         z-index 5  — over the feed
              .topbar (logo, contacts)       z-index 6  — always on top
   The feed text can ride over the headline, but stays under the buttons. */

.chrome {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 1;              /* layer 1 — under the feed */
  pointer-events: none;
}
/* Headline: Anton 68/110%. Alignment answers the mode —
   centre when nothing is chosen, left for Biography, right for Events —
   and the inline logo rides to the opposite side of the first line. */
.headline {
  position: absolute; /* also the containing block for the pinned logo */
  left: 0.2rem;
  right: 0.2rem;
  top: 0.09rem;
  font-family: "Anton", "Red Hat Mono", sans-serif;
  font-size: 0.68rem;
  line-height: 1.1;
  font-weight: 400;
  text-transform: uppercase;
  color: var(--red);
  text-align: center;
}
.headline[data-align="left"]  { text-align: left; }
.headline[data-align="right"] { text-align: right; }

.headline__words { display: block; }
/* each word is its own span so the FLIP transition can move them */
.headline__w {
  display: inline-block;
  white-space: pre;
}
/* adaptive calendar: the headline's visual lines get REAL masks for the
   line-swap (the aside grammar verbatim — without the clip the staggered
   lines overlapped each other, №50). JS wraps/unwraps in calCollapse. */
.hl-line { overflow: hidden; }
.hl-line__inner { display: block; white-space: nowrap; }
/* scramble letters: the noise is DIMMED and every state flip breathes
   through a short opacity transition — shimmer, not strobe */
.headline__w .scr-l { transition: opacity 0.18s linear; }
.headline__w .scr-l.is-noise { opacity: 0.35; }
/* the echo stage: the word's own box is frozen (no line reflow, ever);
   the letters live on a right-anchored layer — a longer replacement
   writes LEFTWARD from the fixed right edge, over the dim neighbours */
.headline__w.is-echo { position: relative; }
.headline__w.is-echo .scr-stage {
  position: absolute;
  top: 0;
  right: 0;
  white-space: pre;
}
/* Mode states: the first line is a flex row [ text | cell | text ]. The two
   text halves are EQUAL (flex: 1), so the cell's centre IS the screen centre
   by construction — the buttons (fixed, left: 50%) land in it exactly, with
   no measuring, no chasing. The text is physically broken around the cell
   and can never run under the buttons. */
.headline__line1 { display: flex; }
.headline__part {
  flex: 1 1 0;
  min-width: 0;
  white-space: nowrap; /* JS fills each half to fit — never wrap inside */
}
.headline__rest { display: block; }
.headline__slot {
  flex: 0 0 4.3rem;
  width: 4.3rem;
  height: 0.44rem;
  margin: 0 0.4rem; /* air between the words and the cell (per mockup) */
  align-self: center;
}
/* centred intro: the cell narrows to LOGO width — it holds the logo, the
   buttons wait below. The first line is justified outward: each half is
   flush with its screen edge, all the air gathers around the central logo. */
.headline[data-align="center"] .headline__slot {
  flex-basis: 1.11rem;
  width: 1.11rem;
}
.headline[data-align="center"] .headline__part:first-of-type { text-align: left; }
.headline[data-align="center"] .headline__part:last-of-type { text-align: right; }

/* The logo is never between words — it lives in the first line's cell
   (screen centre) in the intro, and rides to an END of the line in the
   mode states. One axis, one transform: left/right swaps don't
   interpolate, a translate does — the logo glides, it doesn't jump.
   Headline box is 18.8rem wide, logo 1.11rem:
   centre = (18.8 − 1.11)/2 = 8.845rem, right edge = 17.69rem. */
.logo {
  position: absolute;
  left: 0;
  /* vertically centred on the LETTERS of the first line (cap band), the
     same axis the buttons sit on: 0.464rem − 0.09 (headline top) − half
     of the 0.47rem logo */
  top: 0.14rem;
  width: 1.11rem;
  height: 0.47rem;
  /* the real 100-GSA mark (pulled from 100-gsa.ru), aspect 118:50 —
     matches the 1.11×0.47 box exactly */
  background: url("assets/logo.svg") no-repeat center / contain;
  transition: transform 0.9s cubic-bezier(0.22, 1, 0.36, 1);
}
.headline[data-align="center"] .logo { transform: translateX(8.845rem); }
.headline[data-align="left"]   .logo { transform: translateX(17.69rem); }

/* ---------- bottom bar: contacts (+ register once a mode is on) -------- */

.bottombar {
  position: fixed;
  bottom: 0;
  left: 0;
  right: 0;
  z-index: 6;
  pointer-events: none;
}
.bottombar__row {
  position: absolute;
  bottom: 0.19rem;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  white-space: nowrap;
}
.contacts,
.register {
  font-size: 0.16rem;
  line-height: 0.224rem;
  text-transform: uppercase;
  color: var(--ink);
  pointer-events: auto;
}
/* register only appears once a mode is chosen. While hidden it takes NO
   width (else the visible CONTACTS sits left of centre — the flex pair was
   centred, not the text the viewer actually sees). */
.register {
  max-width: 0;
  margin-left: 0;
  overflow: hidden;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.4s ease, max-width 0.4s ease, margin-left 0.4s ease;
}
body.mode-chosen .register {
  opacity: 1;
  pointer-events: auto;
  max-width: 1.2rem;
  margin-left: 0.24rem;
}
/* calendar mode: the bottom REGISTER dissolves (CONTACTS re-centres via
   the flex row) — each calendar row grows its own hover button instead */
body.cal-open .register {
  opacity: 0;
  pointer-events: none;
  max-width: 0;
  margin-left: 0;
}

/* ---------- registration modal ---------- */

.reg {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s ease;
}
.reg.is-open {
  opacity: 1;
  pointer-events: auto;
}
.reg__veil {
  position: absolute;
  inset: 0;
  background: rgba(255, 255, 255, 0.88);
}
.reg__panel {
  position: relative;
  width: 4.6rem;
  max-width: calc(100vw - 0.4rem);
  background: var(--bg);
  border: 1px solid var(--chip-line);
  border-radius: 0.08rem;
  padding: 0.3rem 0.32rem 0.32rem;
  display: flex;
  flex-direction: column;
  gap: 0.18rem;
  transform: translateY(0.25rem);
  transition: transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
.reg.is-open .reg__panel { transform: translateY(0); }
.reg__close {
  position: absolute;
  /* a real BUTTON in the chip voice (№62; was a bare × glyph) — its
     centre stays on the title's cap line (Anton's symmetry rule) */
  top: 0.27rem;
  right: 0.32rem;
  width: 0.36rem;
  height: 0.36rem;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--chip-line);
  border-radius: 0.08rem;
  background: var(--bg);
  font: inherit;
  font-size: 0.18rem;
  line-height: 1;
  color: var(--ink-40);
  cursor: pointer;
  transition: background-color 0.25s ease, color 0.25s ease;
}
.reg__close:hover { color: var(--ink); background: var(--chip); }
.reg__title {
  font-family: "Anton", "Red Hat Mono", sans-serif;
  font-size: 0.4rem;
  line-height: 1.05;
  text-transform: uppercase;
  color: var(--red);
  margin-bottom: 0.06rem;
}
.reg__field { display: flex; flex-direction: column; gap: 0.05rem; }
.reg__label {
  font-size: 0.13rem;
  line-height: 0.182rem;
  text-transform: uppercase;
  color: var(--ink-40);
}
.reg__input {
  font: inherit;
  font-size: 0.16rem;
  line-height: 0.224rem;
  color: var(--ink);
  background: transparent;
  border: 0;
  border-bottom: 1px solid #dcdcdc;
  border-radius: 0;
  padding: 0.05rem 0;
  outline: none;
  transition: border-color 0.25s ease;
}
.reg__input:focus { border-bottom-color: var(--ink); }
.reg__input.is-bad { border-bottom-color: var(--red); }
.reg__select { appearance: none; cursor: pointer; }
.reg__consent {
  display: flex;
  gap: 0.08rem;
  align-items: flex-start;
  font-size: 0.13rem;
  line-height: 0.182rem;
  color: var(--ink-40);
  cursor: pointer;
  transition: color 0.25s ease;
}
.reg__consent input { margin-top: 0.025rem; accent-color: var(--red); }
.reg__consent a { color: inherit; }
.reg__consent.is-bad { color: var(--red); }
.reg__submit {
  margin-top: 0.06rem;
  font: inherit;
  font-size: 0.16rem;
  line-height: 0.224rem;
  text-transform: uppercase;
  padding: 0.1rem;
  border: 1px solid var(--chip-line);
  border-radius: 0.08rem;
  background: var(--bg);
  color: var(--ink);
  cursor: pointer;
  transition: background-color 0.25s ease, color 0.25s ease,
              border-color 0.25s ease;
}
.reg__submit:hover { background: var(--chip); }
.reg__submit.is-sent {
  background: var(--red);
  border-color: var(--red);
  color: #fff;
}

/* --- calendar rows: per-row REGISTER on hover — a TEXT link in the
   site's caps+underline voice, living INSIDE the event's own container:
   meta →(X)→ title →(2X)→ register (X = the head's 0.08rem gap). The
   hover hit area grows by HALF the inter-row gap on both sides
   (padding + negative margin, net layout unchanged) — Anton's X/2. --- */
.episode--event .episode__head { position: relative; }
.reg-link {
  position: absolute;
  top: calc(100% + 0.16rem); /* 2X below the title */
  left: 50%;
  transform: translate(-50%, -0.08rem);
  border: 0;
  background: none;
  padding: 0;
  font: inherit;
  font-size: 0.16rem;
  line-height: 0.224rem;
  text-transform: uppercase;
  text-decoration: underline;
  color: var(--ink);
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.45s ease,
              transform 0.6s cubic-bezier(0.22, 1, 0.36, 1);
}
.reg-link:hover { color: var(--red); }
@media (min-width: 1101px) {
  /* Converged rows OVERLAP as boxes — CSS :hover let the upper transparent
     box steal the pointer from the visible row (jittery hover, dead
     clicks). So the rows are TRANSPARENT to the pointer and JS assigns
     .is-reg from the mouse position against the stack's own grid —
     seamless zones, read continuously, even while scrolling. Only the
     link itself takes clicks. */
  body.cal-open .episode--event { pointer-events: none; }
  body.cal-open .episode--event.is-reg .reg-link {
    opacity: 1;
    pointer-events: auto;
    transform: translate(-50%, 0);
  }
}
@media (max-width: 1100px) {
  /* no hover on touch: the link lives under each open row — as an
     ABSOLUTE layer (№53, the desktop principle: the calendar must NEVER
     change the flow's geometry, only opacity/transform animate). It
     fades in over the dissolved description's reserved space. ONE
     animation system: leaves in the 300ms exit voice, arrives in the
     780ms house spring (the transition set swaps with the state). */
  .reg-link {
    display: inline-block;
    position: absolute;
    top: calc(100% + 0.08rem); /* 2X below the title (head gap + 0.08) */
    left: 50%;
    /* pure FADE both ways (№55: the little rise still read as a jolt) */
    transform: translate(-50%, 0);
    opacity: 0;
    pointer-events: none;
    transition: opacity 300ms cubic-bezier(0.55, 0, 0.55, 0.2);
  }
  body.cal-open .episode--event .reg-link {
    opacity: 1;
    pointer-events: auto;
    transition: opacity 780ms cubic-bezier(0.22, 1, 0.36, 1);
  }
}

/* ---------- dev panel (temporary) ---------- */

/* the little chip that opens the panel; body.tune-avail shows it
   (localhost/LAN always, elsewhere via #tune) */
.tune-toggle {
  display: none;
  position: fixed;
  right: 0.16rem;
  bottom: 0.16rem;
  z-index: 22;
  padding: 0.05rem 0.1rem;
  border: 1px solid var(--chip-line);
  border-radius: 0.05rem;
  background: #fff;
  font-family: inherit;
  font-size: 0.12rem;
  color: var(--ink);
  cursor: pointer;
  opacity: 0.55;
}
.tune-toggle:hover { opacity: 1; }
/* №55: the chip is RETIRED (Anton) — the panel itself stays reachable
   for us via the #tune hash flow if ever needed again */

.devpanel {
  position: fixed;
  right: 0.16rem;
  bottom: 0.62rem; /* sits above the tune chip */
  z-index: 20;
  background: #fff;
  border: 1px solid var(--chip-line);
  border-radius: 0.06rem;
  padding: 0.1rem 0.12rem;
  font-size: 0.13rem;
  line-height: 1.5;
  color: var(--ink);
  display: flex;
  flex-direction: column;
  gap: 0.02rem;
}
.devpanel[hidden] { display: none; }
.devpanel {
  width: 2.1rem;
  max-height: 88vh;
  overflow-y: auto;
}
.devpanel__title {
  text-transform: uppercase;
  opacity: 0.4;
  margin-bottom: 0.04rem;
}
.devpanel label { display: flex; gap: 0.06rem; align-items: center; cursor: pointer; }
.tune__sec {
  text-transform: uppercase;
  opacity: 0.4;
  margin: 0.1rem 0 0.02rem;
}
.tune__curve {
  width: 100%;
  height: auto;
  border: 1px solid var(--chip-line);
  border-radius: 0.04rem;
  cursor: crosshair;
  touch-action: none;
}
.tune__bezier {
  width: 100%;
  font: inherit;
  font-size: 0.12rem;
  color: var(--ink);
  border: 1px solid var(--chip-line);
  border-radius: 0.04rem;
  padding: 0.03rem 0.05rem;
}
.tune__row { justify-content: space-between; }
.tune__row span { display: flex; align-items: center; gap: 0.03rem; }
.tune__row input[type="number"] {
  width: 0.62rem;
  font: inherit;
  font-size: 0.12rem;
  color: var(--ink);
  text-align: right;
  border: 1px solid var(--chip-line);
  border-radius: 0.04rem;
  padding: 0.02rem 0.04rem;
}
.tune__btns { display: flex; gap: 0.06rem; margin-top: 0.08rem; }
.tune__btns button {
  flex: 1;
  font: inherit;
  font-size: 0.13rem;
  padding: 0.05rem;
  border: 1px solid var(--chip-line);
  border-radius: 0.05rem;
  background: #fff;
  cursor: pointer;
}
.tune__btns button:hover { background: var(--chip); }

/* ---------- scrolling flow ---------- */

/* smooth-scroll viewport: the real scrollbar drives a spacer of the
   right height; .page is translated to follow it with easing */
.scroll {
  position: fixed;
  inset: 0;
  overflow: hidden;
  z-index: 0;
}
.scroll__spacer { width: 1px; }

/* no z-index here: children must stack individually against the fixed
   layers (toggle above the masks, feed content below them).
   NOTE: no `will-change: transform` here. The feed is ~18000px tall; a
   permanent compositor layer that size can exceed the GPU texture limit on
   weaker/older hardware, forcing Chrome to fall back to software rendering
   (→ ~10fps). The transform still animates fine without it — the browser
   promotes it to a layer for the duration of the transform automatically. */
.page {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  width: 19.2rem;
  margin: 0 auto;
}

.intro {
  position: relative;
  height: 5.84rem;
}
.intro__prompt {
  position: absolute;
  top: 6rem; /* №64: a notch lower, onto the portrait's chest — keep in
                sync with promptAnchor() in main.js */
  left: 50%;
  transform: translateX(-50%);
  width: 2.6rem;
  text-align: center;
  text-transform: uppercase;
  font-size: 0.16rem;
  line-height: 0.224rem;
}

.intro-rest { height: 55vh; }

/* mode toggle: fixed chrome. It sits in the intro center, then slides up
   to a docked slot below the header as the intro scrolls away (JS drives
   the vertical position via --toggle-y). */
.toggle {
  position: fixed;
  inset: 0;
  z-index: 5;             /* layer 3 — over the feed, under the top chrome */
  pointer-events: none;
}
.toggle__row {
  position: fixed;
  left: 50%;
  top: 0;
  /* ALWAYS horizontally centred; only the vertical position changes.
     Intro: JS writes --toggle-y every frame (the row rides with the scroll
     below the prompt) — no transition here or the writes would smear. */
  transform: translate(-50%, calc(var(--toggle-y, 6.9rem) - 50%));
  display: flex;
  justify-content: center;
  gap: 0.08rem;
  pointer-events: auto;
  will-change: transform;
  /* NO transition here ever: JS writes --toggle-y every frame (the row
     rides 1:1 with the page and clamps at the first-line cell) — a CSS
     transition chasing per-frame writes is the lag bug (see the logo) */
}
.toggle__btn {
  width: 1.92rem;
  padding: 0.1rem;
  border: 1px solid var(--chip-line);
  border-radius: 0.08rem;
  /* solid fill — the headline words pass BEHIND the buttons during the
     reshuffles, a transparent chip made them flicker through */
  background: var(--bg);
  font-family: inherit;
  font-size: 0.16rem;
  line-height: 0.224rem;
  color: var(--ink);
  cursor: pointer;
  transition: opacity 0.25s ease, background-color 0.25s ease;
}
.toggle.is-engaged .toggle__btn.is-active {
  background: var(--chip);
  border-color: var(--chip);
}
/* dim the LABEL only — an opacity on the whole button made the chip itself
   40%-transparent and the feed text bled right through the "fill" */
.toggle.is-engaged .toggle__btn:not(.is-active) { color: var(--ink-40); }

/* ---------- feed ---------- */

.feed { padding-top: 3.6rem; }
/* mode switch: the feed crossfades instead of hard-swapping — fade out,
   rebuild + reposition under the cover, fade back in (JS drives the class;
   keep the duration in sync with FEED_SWAP_MS in main.js) */
.feed { transition: opacity 0.46s ease; }
.feed.is-swapping { opacity: 0; }

.section {
  position: relative;
  margin-bottom: 3.6rem; /* matches the events rhythm — uniform episode gaps */
}

.episode { width: 7.05rem; }

.episode--main {
  position: relative;
  margin-left: 7.25rem;      /* grid columns 4–6 */
  will-change: transform;
}

.orphans { margin-left: 11.95rem; } /* grid columns 6–8 */
.orphan  { margin-top: 3.4rem; }
.orphans > .orphan:first-child { margin-top: 2.2rem; }

/* events mode: same block anatomy, plain flow, denser rhythm */
.episode--event {
  position: relative;
  margin-left: 7.25rem;
  /* vertical position is JS-driven (translateY) so events can converge on
     the anchor event; margin stays fixed to keep natural layout stable */
  margin-bottom: 3.6rem;
  transition: transform 1.1s cubic-bezier(0.4, 0, 0.2, 1);
}
.episode--event .episode__title { text-transform: none; }
/* description slides left + fades out when the calendar opens; the stagger
   (top leads, lower ones follow) is set per-block by JS via --cal-delay.
   Slow and gentle — the whole project favours calm over speed. */
.episode--event .episode__body {
  transition: transform 1.1s cubic-bezier(0.4, 0, 0.2, 1),
              opacity 1s cubic-bezier(0.4, 0, 0.2, 1);
  transition-delay: var(--cal-delay, 0s);
}

/* big-date calendar block: its number's BASELINE aligns to the title's
   first line; on the far right of the canvas; hidden until open */
.episode__cal {
  position: absolute;
  right: 0; /* the resting spot was right — only the TRAVEL got longer */
  top: 0;
  text-align: left;
  opacity: 0;
  /* start further right (3× the old 0.5rem): a long right-to-left arrival,
     same curve and duration as the body text's exit */
  transform: translateX(1.5rem);
  pointer-events: none;
  transition: opacity 0.9s ease, transform 1.1s cubic-bezier(0.4, 0, 0.2, 1);
  transition-delay: var(--cal-delay, 0s);
}
.episode__cal-day {
  /* one style with the big headline (Anton) — same voice, same red */
  font-family: "Anton", "Red Hat Mono", sans-serif;
  font-size: 0.6rem;
  line-height: 0.6rem;
  color: var(--red);
}
.episode__cal-weekday {
  margin-top: 0.12rem;
  font-size: 0.16rem;
  line-height: 0.224rem;
  text-transform: uppercase;
  color: var(--ink-40);
}

/* ----- calendar OPEN state (Events only) ----- */
body.cal-open .episode--event .episode__body {
  transform: translateX(-1.2rem);
  opacity: 0;
}
body.cal-open .episode__cal {
  opacity: 1;
  transform: translateX(0);
}
/* dim (not remove) the left biography zone — it stays frozen at ~20% */
.aside { transition: opacity 0.9s ease; }
body.cal-open .aside { opacity: 0.2; }
/* adaptive: the UPPER control-position text stays LIVE, barely-there
   (Anton №47: 10%; it must come AFTER the 0.2 rule to win the cascade) */
@media (max-width: 1100px) {
  body.cal-open .aside { opacity: 0.1; }
}

/* calendar toggle button: fixed on the right edge, Events only */
.cal-btn {
  position: fixed;
  right: 0.4rem;
  top: 50vh;
  transform: translateY(-50%);
  width: 0.44rem;
  height: 0.44rem;
  border: 1px solid var(--chip-line);
  border-radius: 0.06rem;
  background: #fff;
  cursor: pointer;
  z-index: 11;
  display: none; /* shown only in events mode */
  align-items: center;
  justify-content: center;
  padding: 0;
}
body.mode-events .cal-btn { display: flex; }
/* №67: the calendar glyph (was a rotating plus/×) — the ink grey of the
   typography at rest, the brand red on hover and while the calendar is
   open; the SVG strokes with currentColor, so color IS the paint */
.cal-btn__icon {
  display: block;
  width: 0.16rem;
  height: 0.16rem;
  color: var(--ink);
  transition: color 0.25s ease;
}
.cal-btn:hover .cal-btn__icon { color: var(--red); }
body.cal-open .cal-btn__icon { color: var(--red); }
/* №68: while the calendar is open the chip's border answers in red too */
body.cal-open .cal-btn { border-color: var(--red); }

.episode__head {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.08rem;
  width: 4.7rem;
}
/* the orphan head's horizontal dodge is scroll-driven; a transition on the
   transform smooths it so it eases into place instead of snapping, no matter
   how fast the scroll crosses the trigger window. No permanent will-change:
   the transition promotes it to a layer only while animating, so we don't
   pin 12 idle GPU layers in memory the whole time. */
.episode--orphan .episode__head {
  /* heavier, calmer dodge (Anton: the title slid aside too fast) */
  transition: transform 0.85s cubic-bezier(0.22, 1, 0.36, 1);
}

.episode__year {
  font-size: 0.16rem;
  line-height: 0.224rem;
  text-transform: uppercase;
  color: var(--ink-40);
}
.episode__title {
  width: 4.7rem;
  font-size: 0.2rem;
  line-height: 0.28rem;
  font-weight: 400;
  text-align: center;
}
.episode__body {
  margin-top: 0.12rem;
  margin-left: 2.4rem;
  width: 4.7rem;
  font-size: 0.16rem;
  line-height: 0.224rem;
}
.episode__echo { color: var(--ink-40); }

/* №73 (препод): orphan episodes step DOWN a size — title −3, body −2 —
   so the side stories read subordinate to the mains. DESKTOP ONLY (the
   adaptive tiers keep their unified scale); the media guard keeps this
   two-class rule from outweighing the tablet/mobile overrides. */
@media (min-width: 1101px) {
  .orphan .episode__title { font-size: 0.17rem; line-height: 0.24rem; }
  .orphan .episode__body { font-size: 0.14rem; line-height: 0.196rem; }
}

/* ============================================================
   TABLET (701–1100, портрет iPad) — ЕДИНАЯ адаптивная схема (№43), the
   1|2 variant switcher is GONE. Zero: red headline on top, centred, logo
   in the mid-line cell; prompt + buttons near the bottom. Chosen mode:
   the headline re-assembles at the BOTTOM (flat flow, alignment answers
   the mode, logo on the opposite edge); on top — the white gradient zone
   with the control position and the pinned head of the current episode;
   the feed rides beneath them and fades away under the gradient.
   KEEP IN SYNC with isTablet()/isMobile() and the adaptive numbers in
   main.js (switch line, dock, anchors).
   ============================================================ */
@media (min-width: 701px) and (max-width: 1100px) {
  html { font-size: calc(100vw / 8.34); }
  .canvas, .page { width: 8.34rem; }

  /* --- headline: zero on top (cell + centred logo), chosen at bottom.
     №61 (Anton's mock): 46px @820 canvas → 0.47rem; in the mode states
     LINE 1 is its own DIV [logo | text] and the REST flows from the
     mode's edge — the ragged side gives the echo replacements room. --- */
  .headline { font-size: 0.47rem; }
  .headline[data-align="center"] .headline__slot {
    flex-basis: 0.6rem;
    width: 0.6rem;
    margin: 0 0.3rem;
    height: 0.55rem;
  }
  .logo {
    width: 0.6rem;
    height: 0.25rem;
    top: 0.15rem;
  }
  /* headline box is 7.94rem wide here, logo 0.6rem */
  .headline[data-align="center"] .logo { transform: translateX(3.67rem); }
  /* mode states (№61→№62): bio — logo left + text LEFT-set (ragged
     right); events — logo right + text RIGHT-set (ragged left). The
     BASE data-align rules already say exactly that — no swap here.
     Line 1 is a flex row, the logo cap-sized. */
  .headline__l1 { display: flex; align-items: flex-start; }
  .headline__l1t { flex: 1 1 0; min-width: 0; white-space: nowrap; }
  .headline[data-align="left"] .logo,
  .headline[data-align="right"] .logo {
    position: static;
    float: none;
    transform: none;
    width: 0.8rem;        /* 118:50 aspect × the 0.34rem cap */
    height: 0.34rem;
    margin-top: 0.065rem; /* cap top of the first line */
  }
  .headline[data-align="left"] .logo { margin-right: 0.2rem; }
  .headline[data-align="right"] .logo { margin-left: 0.2rem; }
  /* the echo stage grows INTO the ragged edge (№62): bio text is
     LEFT-set → ragged RIGHT → the stage anchors LEFT and writes
     rightward; events text is RIGHT-set → ragged LEFT → the default
     right anchor already writes leftward into the void */
  .headline[data-align="left"] .headline__w.is-echo .scr-stage {
    right: auto;
    left: 0;
  }
  /* the red block moves HOME to the bottom once a mode is chosen.
     .chrome has no height — anchor the headline itself upward from it */
  /* above the REGISTER/CONTACTS line, never over it */
  body.mode-chosen .chrome { top: auto; bottom: 0.65rem; }
  body.mode-chosen .headline { top: auto; bottom: 0; }
  /* №60: the chips keep the DESKTOP proportions here (the 1.3rem
     override read «милипиздрически» on the pad) — base rules apply */

  /* --- type: a notch larger — 0.16/0.2rem read too small on the pad --- */
  .episode__title { font-size: 0.24rem; line-height: 0.336rem; }
  .episode__body,
  .episode__year { font-size: 0.18rem; line-height: 0.252rem; }
  .aside__title { font-size: 0.24rem; line-height: 0.336rem; }
  .aside__meta { font-size: 0.18rem; line-height: 0.252rem; }
  .episode__cal-day { font-size: 0.48rem; line-height: 0.48rem; }

  /* --- the chosen-mode top zone --- */
  .mask--top { display: block; height: 4rem; }
  /* the BOTTOM zone mirrors it (Anton №47, «слоёный пирог»): a mostly
     solid white panel under the red block — the feed passes UNDER it,
     never through the translucent red words */
  .mask--bottom {
    /* tall enough that the SOLID part starts above the 5-line red block */
    height: 4.6rem;
  }
  .mask__full,
  .mask__cal {
    background: linear-gradient(
      to top,
      var(--bg) 0%,
      var(--bg) 76%,
      rgba(255, 255, 255, 0) 100%
    );
  }
  .aside { z-index: 4; }
  .aside__box {
    top: 0.25rem;
    left: 0.2rem;
    width: 7.94rem;
  }
  .pinhead {
    display: block;
    /* №66: fallback only — placeCtrlZone hangs it 0.32rem under the
       aside's REAL content (a five-line event used to overrun this
       fixed perch and overlap the head) */
    top: 2rem;
    left: 50%;
    width: 7.94rem;
  }
  .pinhead__year { font-size: 0.18rem; line-height: 0.252rem; }
  .pinhead__title { font-size: 0.24rem; line-height: 0.336rem; }

  /* --- intro: prompt + buttons near the bottom, like the phone --- */
  /* №65: much bigger (Anton: «раза в 2») — wider than the canvas, the
     stray sketch-lines clip at the screen edges, the face stays whole */
  .hero__img {
    left: -0.83rem; /* (8.34 − 10) / 2 — centre */
    top: 0.6rem;
    width: 10rem;
    height: 9.08rem; /* 10 × 1220/1344 */
  }
  /* №78: --vh is the JS innerHeight (layout() writes it) — the buttons'
     ride is JS-placed from the same number; a CSS 100vh here is the
     LARGE viewport (browser bars collapsed) and sat lower than the ride
     by the bar height, so the chips overlapped the prompt on real phones */
  .intro__prompt { top: calc(var(--vh, 100vh) - 1.7rem); }

  /* --- feed: one full-width column under the top zone; orphans read
     apart by their LEFT-set head (mains keep the centred one), tight to
     their main while sections keep the big air --- */
  .feed { padding-top: 2rem; } /* №52→№53: the first episode arrives early */
  .section { margin-bottom: 4.2rem; }
  .episode { width: 7.94rem; }
  .episode--main,
  .episode--event { margin-left: 0.2rem; }
  .episode--event { margin-bottom: 4.2rem; }
  .episode__head { width: 7.94rem; }
  .episode__title { width: 7.94rem; }
  .episode__body { margin-left: 0; width: 7.94rem; }
  .orphans { margin-left: 0.2rem; }
  .orphan { margin-top: 2.2rem; }
  .orphans > .orphan:first-child { margin-top: 1.6rem; }
  .orphan .episode__head,
  .orphan .episode__title,
  .orphan .episode__body { width: 7.94rem; }
  .orphan .episode__head { align-items: flex-start; }
  .orphan .episode__title { text-align: left; }
  /* №74: orphans step down here too (Anton liked the desktop move) */
  .orphan .episode__title { font-size: 0.21rem; line-height: 0.294rem; }
  .orphan .episode__body { font-size: 0.16rem; line-height: 0.224rem; }

  /* --- calendar open: NOTHING squeezes — everything stays on the centre
     axis; the dissolve/unfold rules live in the shared ≤1100 block (№50),
     only the tablet's roomier date gap differs --- */
  body.cal-open .episode__cal { margin-top: 0.2rem; }
}

/* ============================================================
   MOBILE (≤700px). The bottom-panel logic IS the layout (no variants,
   the switcher hides): episodes are one full-width column on the lowest
   layer, flying in from behind the white bottom panel; the driving zone
   renders on the panel above the bottom bar; the switch trigger is the
   panel's top edge (2.2rem — keep in sync with render() in main.js).
   Orphans differ from mains by the indent alone. Canvas 390 (iPhone 12
   Pro): 1rem = 100vw/3.9, so 0.16rem = 16px — readable type by design.
   The headline is a plain block (no cell — no room between words at this
   width); the buttons dock over the faded headline once a mode is on.
   ============================================================ */
@media (max-width: 700px) {
  html { font-size: calc(100vw / 3.9); }
  .canvas, .page { width: 3.9rem; }

  /* --- header (Anton's mock): 10px side margins, Anton 24/110%/−2%.
     Zero: centre with the logo in the mid-line cell. Mode states: the
     logo FLOATS in the first line (bio — left, events — right) — the
     «втяжка»: line 1 is short of it, the rest full width. --- */
  .headline {
    font-size: 0.24rem;
    letter-spacing: -0.02em;
    left: 0.1rem;
    right: 0.1rem;
  }
  .headline__slot { margin: 0 0.12rem; height: 0.26rem; }
  .headline[data-align="center"] .headline__slot {
    flex-basis: 0.4rem;
    width: 0.4rem;
  }
  .logo {
    width: 0.4rem;
    height: 0.17rem;
    top: 0.05rem; /* centred on the first line's cap band */
  }
  /* headline box is 3.7rem wide here, logo 0.4rem */
  .headline[data-align="center"] .logo { transform: translateX(1.65rem); }
  /* mode states: the logo is a FLOAT inside the words flow (см. мок),
     sized to the CAP HEIGHT of the 0.24rem Anton line and top-aligned
     with the letters (Anton №47) */
  .headline[data-align="left"] .logo,
  .headline[data-align="right"] .logo {
    position: static;
    transform: none;
    width: 0.41rem;       /* 118:50 aspect × the 0.175rem cap */
    height: 0.175rem;
    margin-top: 0.03rem; /* cap top of the first line (№49: a notch up) */
  }
  .headline[data-align="left"] .logo { float: left; margin-right: 0.12rem; }
  .headline[data-align="right"] .logo { float: right; margin-left: 0.12rem; }
  /* buttons: two HALF-WIDTH chips, 24px tall (№48; was the mock's 32),
     riding to their dock right above the bottom headline */
  .toggle__row {
    width: 3.7rem;
    gap: 0.1rem; /* №63: Anton's 10px between the chips */
  }
  .toggle__btn {
    flex: 1 1 0;
    width: auto;
    height: 0.24rem;
    padding: 0 0.1rem;
    font-size: 0.13rem;
  }
  /* the dock lives in the JS ride-clamp (dockTyPx) — keep in sync */

  /* --- type back to the mobile scale (№49: 15px headings, 13px body and
     small caps meta — Anton walked the №48 11px back) */
  .episode__title { font-size: 0.15rem; line-height: 0.21rem; }
  .episode__body { font-size: 0.13rem; line-height: 0.182rem; }
  .episode__year { font-size: 0.13rem; line-height: 0.182rem; }
  .aside__title { font-size: 0.15rem; line-height: 0.21rem; }
  /* №63: the date hugs its event — the same tight stack as the pinned
     head's year/title (Anton liked THAT distance) */
  .aside__meta { margin-top: 0; font-size: 0.13rem; line-height: 0.182rem; }
  .episode__cal-weekday { font-size: 0.13rem; line-height: 0.182rem; }
  .contacts,
  .register { font-size: 0.13rem; line-height: 0.182rem; }
  /* the per-row register matches the CONTACTS size (№55) */
  .reg-link { font-size: 0.13rem; line-height: 0.182rem; }
  /* the mock's order: REGISTER, then CONTACTS */
  .register { order: -1; margin-left: 0; }
  body.mode-chosen .register { margin-left: 0; margin-right: 0.24rem; }
  body.cal-open .register { margin-right: 0; } /* CONTACTS re-centres */

  /* --- intro: prompt + buttons live at the very BOTTOM of the screen
     (headline on top, controls at the thumb). The buttons' ride anchor
     (vh − 0.9rem) lives in rideAnchor() in main.js — keep in sync. --- */
  .intro__prompt {
    top: calc(var(--vh, 100vh) - 1.7rem); /* №78: the JS innerHeight, see tablet */
    font-size: 0.13rem;
    line-height: 0.182rem;
  }
  /* №65: much bigger (Anton: «раза в 2») — wider than the screen, the
     stray sketch-lines clip at the edges, the face stays whole */
  .hero__img {
    left: -1.25rem; /* (3.9 − 6.4) / 2 — centre */
    top: 0.9rem;
    width: 6.4rem;
    height: 5.81rem; /* 6.4 × 1220/1344 */
  }

  /* calendar toggle hugs the phone's edge (0.4rem read as a stray gap).
     №78: its right edge sits ON the chips' right edge — the 3.7rem row
     is inset 0.1rem, and the 0.16 left the button hanging 6px inboard */
  .cal-btn { right: 0.1rem; }

  /* --- the chosen-mode TOP zone (№43): white gradient down from the top,
     the control position and the pinned episode head sit on it; the feed
     fades away beneath. The red headline moves HOME to the bottom. --- */
  .mask--top { display: block; height: 3.9rem; }
  /* the BOTTOM zone mirrors it (Anton №47, «слоёный пирог»): a mostly
     solid white panel under the red block — the feed passes UNDER it */
  .mask--bottom { height: 2.6rem; }
  .mask__full,
  .mask__cal {
    background: linear-gradient(
      to top,
      var(--bg) 0%,
      var(--bg) 72%,
      rgba(255, 255, 255, 0) 100%
    );
  }
  /* above the REGISTER/CONTACTS line, never over it (№63: a notch
     closer — keep in sync with dockTyPx's 0.47 in main.js) */
  body.mode-chosen .chrome { top: auto; bottom: 0.47rem; }
  body.mode-chosen .headline { top: auto; bottom: 0; }
  .aside { z-index: 4; }
  .aside__box {
    top: 0.2rem;
    left: 0.2rem;
    width: 3.5rem;
  }
  /* №63: the top zone is RUBBER — JS (placeCtrlZone) hangs the pinhead
     0.32rem under the aside's real content and re-sizes the mask so its
     solid edge (66%) meets the switch line; the CSS numbers here are the
     empty-state fallback only. (The height transition lives in the
     shared adaptive block — №66 gave the tablet the same rubber.) */
  .pinhead {
    display: block;
    top: 2rem; /* fallback — JS owns it once the control position fills */
    left: 50%;
    width: 3.5rem;
  }
  .pinhead__year { font-size: 0.13rem; line-height: 0.182rem; }
  .pinhead__title { font-size: 0.15rem; line-height: 0.21rem; }

  /* --- feed: one column, full width for EVERY episode (no narrowed
     orphan boxes — they broke the ragged wraps). Orphans read apart by
     their LEFT-set head against the mains' centred one, and by sitting
     TIGHT under their main while sections keep a big air between them.
     (№52: padding halved with the shorter start — the first episode
     must arrive right after the commit) */
  .feed { padding-top: 1.8rem; }
  .section { margin-bottom: 3.4rem; }
  .episode { width: 3.5rem; }
  .episode--main,
  .episode--event { margin-left: 0.2rem; }
  .episode--event { margin-bottom: 3.4rem; }
  .episode__head { width: 3.5rem; }
  .episode__title { width: 3.5rem; }
  .episode__body { margin-left: 0; width: 3.5rem; }
  .orphans { margin-left: 0.2rem; }
  .orphan { margin-top: 1.1rem; }
  .orphans > .orphan:first-child { margin-top: 0.8rem; }
  .orphan .episode__head,
  .orphan .episode__title,
  .orphan .episode__body { width: 3.5rem; }
  .orphan .episode__head { align-items: flex-start; }
  .orphan .episode__title { text-align: left; }
  /* №74: orphans step down here too — gently, the phone scale is tight */
  .orphan .episode__title { font-size: 0.13rem; line-height: 0.182rem; }
  .orphan .episode__body { font-size: 0.12rem; line-height: 0.168rem; }

  /* --- calendar: NO columns and no convergence on mobile (JS skips it) —
     the dissolve/unfold rules live in the shared ≤1100 block (№50) --- */
  .episode__cal-day { font-size: 0.4rem; line-height: 0.4rem; }
}

/* ============================================================
   ADAPTIVE CALENDAR — the DESKTOP PRINCIPLE (№53): the calendar never
   changes the flow's geometry. The description dissolves IN PLACE (its
   height stays reserved), the big date and the register link are
   ABSOLUTE layers fading in over that reserved space — only opacity and
   transform ever animate, so nothing in the feed can jolt. Placed LAST
   so the cascade beats the desktop calendar rules above.
   Voices: leave 300ms (0.55,0,0.55,0.2), arrive 780ms (0.22,1,0.36,1).
   ============================================================ */
@media (max-width: 1100px) {
  /* №54: the intro block shrinks (5.84rem was desktop pacing) — the
     first episode arrives right behind the commit */
  .intro { height: 3.5rem; }
  /* №63: the open calendar rides OVER the control position — the feed
     lifts above the top mask (3) and the aside/pinhead (4), still under
     the chips (5, later in the DOM), links (6) and the cal button (11).
     JS adds cal-ride for the whole open + closing-fade span; the
     per-episode fades in render() hand the cover back to the mask. */
  body.cal-ride .scroll { z-index: 5; }
  /* the rubber top zone (№63 mobile, №66 tablet): the JS-driven mask
     height glides so per-episode swaps don't snap the fade edge */
  .mask--top { transition: height 0.4s ease; }
  .episode--event .episode__body {
    transform: none;
    transition: opacity 780ms cubic-bezier(0.22, 1, 0.36, 1);
  }
  body.cal-open .episode--event .episode__body {
    opacity: 0;
    transform: none;
    transition: opacity 300ms cubic-bezier(0.55, 0, 0.55, 0.2);
  }
  /* the date sits over the dissolved description (top set by JS in
     alignCalendar — the head's height varies per event) */
  .episode__cal {
    display: block;
    position: absolute;
    left: 0;
    right: 0;
    top: 0; /* per-event: JS */
    text-align: center;
    opacity: 0;
    transform: translateY(0.12rem);
    pointer-events: none;
    transition: opacity 300ms cubic-bezier(0.55, 0, 0.55, 0.2),
                transform 300ms cubic-bezier(0.55, 0, 0.55, 0.2);
  }
  body.cal-open .episode__cal {
    opacity: 1;
    transform: translateY(0);
    transition: opacity 780ms cubic-bezier(0.22, 1, 0.36, 1),
                transform 780ms cubic-bezier(0.22, 1, 0.36, 1);
  }
}
