/*
 * Inter Tight — the timeline text block's display family (YEAR, TITLE,
 * and the intro's NAME headline, which now shares TITLE's visual
 * language per the "editorial, not corporate" typography pass). No
 * actual Inter Tight font file ships with this build: this environment
 * has no route to Google Fonts, npm, or any CDN (registry.npmjs.org,
 * fonts.googleapis.com, and raw.githubusercontent.com all refuse the
 * connection outright — the same constraint documented in the README for
 * why this isn't a React/GSAP/Lenis build). An @font-face pointing at a
 * file that doesn't exist is still safe to ship, though — the browser
 * just falls through to the next family in the stack (a system sans
 * that's a close cousin of Inter Tight's proportions), so nothing
 * breaks. Drop a real Inter Tight .woff2 into /fonts and this activates
 * automatically. Replaces the earlier Onest/Montserrat pairing — this
 * pass consolidates the whole text block onto one type family instead of
 * mixing two, which is what "editorial, not decorative" called for.
 */
@font-face {
  font-family: "Inter Tight";
  src:
    url("../fonts/InterTight-Variable.woff2") format("woff2-variations"),
    url("../fonts/InterTight-Variable.woff2") format("woff2");
  font-weight: 100 900;
  font-display: swap;
}

/*
 * Inter — the DESCRIPTION/body register (regular weight prose), a
 * deliberately different family from Inter Tight's tighter display
 * tracking. Same no-internet constraint as above.
 */
@font-face {
  font-family: "Inter";
  src:
    url("../fonts/Inter-Variable.woff2") format("woff2-variations"),
    url("../fonts/Inter-Variable.woff2") format("woff2");
  font-weight: 100 900;
  font-display: swap;
}

/*
 * Montserrat — back, but scoped narrowly this time: the intro's NAME
 * headline (.scene-year--name) only, per an explicit later request
 * ("Арсентьев Владимир — шрифт Montserrat, размером меньше"). YEAR and
 * TITLE stay on Inter Tight. Same no-internet constraint as above.
 */
@font-face {
  font-family: "Montserrat";
  src:
    url("../fonts/Montserrat-Variable.woff2") format("woff2-variations"),
    url("../fonts/Montserrat-Variable.woff2") format("woff2");
  font-weight: 100 900;
  font-display: swap;
}

:root {
  --ink: #14161a;
  --paper: #f3f2ee;
  --muted: #6b6f76;
  --accent: #4a5b8c;
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --nav-h: 84px;
  --font-inter-tight: "Inter Tight", "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Helvetica, Arial, sans-serif;
  --font-inter: "Inter", -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", Helvetica, Arial, sans-serif;
  --font-montserrat: "Montserrat", -apple-system, BlinkMacSystemFont, "SF Pro Display", "Helvetica Neue", Arial, sans-serif;
}

* { box-sizing: border-box; }
/* Native anchor-scroll (the nav's Contact -> #contact link) animates
   instead of jumping — SmoothScroll doesn't hijack scroll (see its own
   doc comment), so this is what actually makes that jump smooth; its
   lerp on top just eases the *visuals* reacting to whatever scrollY the
   native animation is currently at. Off under reduced motion, matching
   every other motion toggle in this file (html.reduced-motion, set by
   main.ts's boot() from the same REDUCED_MOTION check everything else
   in the app gates on). */
html { scroll-behavior: smooth; }
html.reduced-motion { scroll-behavior: auto; }
html, body { margin: 0; padding: 0; }
body {
  background: var(--ink);
  color: var(--paper);
  font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Inter", "Segoe UI", Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}
img, video, canvas { -webkit-user-drag: none; user-select: none; }
a { color: inherit; }
#app { opacity: 0; transition: opacity 400ms var(--ease-out); }
#app.is-ready { opacity: 1; }

/* ===================== boot loader ===================== */
.boot-loader {
  position: fixed; inset: 0; z-index: 1000;
  background: var(--ink); color: var(--paper);
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 18px;
  transition: opacity 450ms var(--ease-out);
}
.boot-loader.is-done { opacity: 0; pointer-events: none; }
.boot-loader-fill {
  width: min(260px, 60vw); height: 2px; background: rgba(243,242,238,0.18);
  position: relative; overflow: hidden;
}
.boot-loader-fill span {
  position: absolute; inset: 0; background: var(--paper);
  transform-origin: left center; transform: scaleX(0);
  transition: transform 120ms linear;
  display: block;
}
.boot-loader-label { font-size: 13px; letter-spacing: 0.04em; color: var(--muted); font-variant-numeric: tabular-nums; }

/* ===================== nav ===================== */
.site-nav {
  position: fixed; top: 0; left: 0; right: 0; z-index: 200;
  height: var(--nav-h);
  display: flex; align-items: center; justify-content: space-between;
  padding: 0 clamp(20px, 4vw, 56px);
  pointer-events: none;
  color: var(--ink);
  transition: color 320ms var(--ease-out);
}
.site-nav.theme-dark { color: var(--paper); }
/* Wordmark is now a link to #top (the first slide) — reset it back to
   looking exactly like the plain text it replaced (inherited color, no
   underline), then add the same hover feedback already used for the
   other nav links so it reads as clickable without changing its resting
   appearance. */
.site-nav-name {
  font-weight: 600; font-size: 15px; letter-spacing: 0.01em; pointer-events: auto;
  color: inherit; text-decoration: none;
  transition: opacity 150ms var(--ease-out);
}
.site-nav-name:hover { opacity: 0.75; }
.site-nav-links { display: flex; gap: clamp(18px, 2.4vw, 34px); font-size: 14px; pointer-events: auto; }
.site-nav-links a { text-decoration: none; opacity: 0.75; transition: opacity 150ms var(--ease-out); }
.site-nav-links a:hover { opacity: 1; }

/* ===================== progress bar + scene counter ===================== */
.progress-bar {
  position: fixed; top: 0; left: 0; width: 100%; height: 2px;
  background: var(--accent);
  transform-origin: left center; transform: scaleX(0);
  z-index: 300;
}
.scene-counter {
  position: fixed; right: clamp(20px, 4vw, 40px); bottom: 28px; z-index: 200;
  font-size: 12px; letter-spacing: 0.08em; color: var(--paper);
  opacity: 0; transition: opacity 320ms var(--ease-out);
  font-variant-numeric: tabular-nums;
  mix-blend-mode: difference;
}
.scene-counter.is-visible { opacity: 0.7; }

/* ===================== SECTION 01 — sequence ===================== */
.sequence-stage {
  position: relative;
  z-index: 1;
  background: var(--paper);
}
.sequence-pin {
  position: sticky; top: 0; height: 100vh; width: 100vw;
  overflow: hidden;
  will-change: transform;
}
canvas.sequence-canvas {
  position: absolute; inset: 0; width: 100%; height: 100%; display: block;
}

.scroll-hint {
  position: absolute; left: 50%; bottom: 40px; transform: translateX(-50%);
  display: flex; flex-direction: column; align-items: center; gap: 10px;
  color: var(--ink); font-size: 12px; letter-spacing: 0.1em; text-transform: uppercase;
  z-index: 5; transition: opacity 240ms var(--ease-out);
  mix-blend-mode: difference; filter: invert(1);
}
.scroll-hint-arrow { animation: hint-bob 1.8s var(--ease-out) infinite; font-size: 14px; }
@keyframes hint-bob { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(6px); } }
html.reduced-motion .scroll-hint-arrow { animation: none; }

.scene-layer { position: absolute; inset: 0; z-index: 4; pointer-events: none; }
/*
 * TEXT LEFT × VISUAL RIGHT — every scene (intro included) shares this
 * exact same left column; only the vertical anchor differs by type, and
 * only the three text lines inside cross-fade otherwise.
 *
 * Timeline scenes (default .scene): anchored TOP — the block's top edge
 * sits a fixed 196px from the viewport's top edge (padding-top below,
 * clear of --nav-h's 84px) and grows downward from there via flex-start,
 * so a longer description extends further down rather than pushing the
 * whole block upward off a fixed bottom line. A hard 196px rather than a
 * clamp()/vh value — the brief called for a precise, non-responsive
 * offset from the screen edge on this breakpoint (mobile keeps its own
 * tuned clamp() below, since a fixed 196px would eat too much of a short
 * phone viewport).
 *
 * Intro (.scene--intro, below): anchored BOTTOM instead — a later spec
 * explicitly split this from the timeline scenes ("первый блок... к
 * низу, блоки с годом... к верху"), so the name/descriptor sit low like
 * the original layout while every year scene stays top-anchored.
 */
.scene {
  position: absolute; left: 0; top: 0; width: 100%; height: 100%;
  display: flex; flex-direction: column; justify-content: flex-start; align-items: flex-start;
  text-align: left;
  padding: 196px clamp(28px, 6.5vw, 120px) 40px;
  will-change: transform, opacity, filter;
}
.scene--intro {
  justify-content: flex-end;
  padding-top: 0;
  padding-bottom: clamp(72px, 14vh, 160px);
}
/*
 * TIME → PROFESSION → MEANING. A later pass rebuilt this hierarchy from
 * scratch around one goal: editorial and premium rather than corporate —
 * "не пытайся сделать текст эффектным, сделай типографику настолько
 * сильной, чтобы ей требовалось минимум эффектов." Concretely: one type
 * family across the whole block (Inter Tight for display, Inter for
 * prose) instead of two competing ones, a lighter YEAR that reads as
 * graphic texture rather than a bold headline, a TITLE that carries all
 * the contrast, and a deliberate optical rhythm between the three lines
 * instead of mechanical fixed gaps.
 *
 * YEAR is still the biggest thing on screen, but visual WEIGHT (size ×
 * boldness × fill) is dialed down from the previous pass: medium weight
 * instead of black, and — same single-opacity-source approach as
 * before — a solid, fully opaque ink color combined with a low animated
 * ceiling (maxOpacity 0.10 in YEAR_STYLE, ScrollScene.ts) rather than a
 * translucent CSS color, so there's one control (JS opacity) producing
 * the soft 10%-alpha field instead of two independent alpha channels
 * fighting each other. It never competes with TITLE for attention.
 */
.scene-year {
  display: block; font-family: var(--font-inter-tight); font-weight: 500; color: rgb(18, 22, 30);
  margin: 0;
  /* Sized down from clamp(220px, 27vw, 460px) — at the widest vw step the
     glyphs' rendered (max-content) width could exceed what .reveal-mask's
     flex-shrink-to-fit wrapper allotted it, clipping the outer digits at
     the mask's overflow:hidden edge. Paired with .reveal-mask--year's
     width: max-content below (belt-and-suspenders: that alone would have
     fixed the shrink-to-fit ambiguity, but a smaller figure is also just
     the more legible call at this scale). */
  font-size: clamp(180px, 22vw, 380px); line-height: 0.72; letter-spacing: -0.075em;
}
.scene-year--name {
  /* The intro has no year figure to anchor on, so the name fills that
     slot instead — sharing TITLE's full-contrast, full-opacity treatment
     and masked-line-reveal animation (NAME_STYLE mirrors TITLE_STYLE in
     ScrollScene.ts) rather than YEAR's soft-field one. Font family is
     Montserrat specifically here (a later, explicit request), NOT
     Inter Tight like YEAR/TITLE — deliberately its own family, same as
     the numeric YEAR figure used before this pass. Sized down from the
     previous clamp(54px, 8vw, 120px) per the same request. */
  font-family: var(--font-montserrat);
  font-weight: 600; font-size: clamp(40px, 6vw, 88px);
  line-height: 0.95; letter-spacing: -0.05em;
  margin: 0;
  max-width: 12ch;
}
/* TITLE is the one contrast-carrying element — full opacity, tighter
   line-height than before (0.98) so it sits close and confident under
   YEAR's generous margin-bottom, rather than floating. No margin of its
   own: the YEAR → TITLE gap comes entirely from .scene-year's
   margin-bottom, and the TITLE → DESCRIPTION gap entirely from
   .scene-body's margin-top below — one gap value per relationship
   instead of two adjacent margins that have to be reasoned about
   together. Lives inside a .reveal-mask wrapper (masked line reveal —
   see ScrollScene.ts) rather than animating directly. */
.scene-title {
  font-family: var(--font-inter-tight);
  font-weight: 600; color: #17181c; margin: 0;
  font-size: clamp(42px, 4vw, 68px); line-height: 0.98; letter-spacing: -0.045em;
  max-width: 20ch;
}
.scene-body {
  /* Same single-opacity-source approach as YEAR: a solid, fully opaque
     ink and an animated ceiling (BODY_STYLE.maxOpacity = 0.68) instead of
     a translucent CSS color, landing on the same rgba(23,24,28,0.68)
     the brief specified without introducing a second alpha channel. */
  font-family: var(--font-inter); font-weight: 400; color: rgb(23, 24, 28); margin: 0;
  margin-top: clamp(24px, 2.2vw, 32px);
  font-size: clamp(20px, 1.65vw, 28px); line-height: 1.28; letter-spacing: -0.015em; max-width: 520px;
}
.scene-body--intro {
  /* Slightly larger register than the timeline DESCRIPTION — the intro
     descriptor sits directly under a headline rather than a title line,
     so it can afford a touch more size without crowding. */
  font-size: clamp(22px, 2.2vw, 36px); line-height: 1.25;
}
/* Masked line reveal wrapper for YEAR, TITLE, and NAME (ScrollScene.ts
   wraps all three in this) — overflow: hidden is the mask itself; the
   actual translateY(105% -> 0)/opacity animation happens on the child
   element inside, so this wrapper never gets a transform or opacity of
   its own. No fixed height here: it sizes to its single child's natural
   (untransformed) BORDER box — deliberately margin:0 on that child
   (.scene-year/.scene-title/.scene-year--name all carry zero margin) so
   the wrapper's clip rectangle exactly matches the visible glyphs, with
   no extra slack at the bottom. A margin equal to the child's old
   margin-bottom would otherwise get included in the wrapper's own
   auto-height (margins aren't clipped out of a parent's flow-height by
   overflow: hidden), stretching the mask taller than the text and
   exposing a sliver of whatever's sitting behind it — which is exactly
   what happened when YEAR was first wrapped here, still carrying its own
   margin-bottom; see .reveal-mask--year below for the fix. */
.reveal-mask { display: block; overflow: hidden; }
/* YEAR's own gap to TITLE (previously .scene-year's margin-bottom) has
   to live on the WRAPPER instead of the child now that YEAR is masked —
   a margin stays outside a wrapper's own clip box even though it would
   inflate that box's auto-height if left on the child (see the comment
   above). TITLE/NAME's wrappers don't need this: those two lines' own
   margin is already 0, with spacing handled entirely by DESCRIPTION's
   margin-top instead. */
/* width: max-content forces the wrapper to size exactly to YEAR's own
   rendered glyph width, regardless of how the surrounding flex column
   would otherwise resolve a shrink-to-fit width for it — removes any
   chance of the overflow:hidden mask clipping the digits horizontally. */
.reveal-mask--year { margin-bottom: clamp(52px, 5vw, 84px); width: max-content; max-width: 100%; }
/* Even with the mask sized exactly to YEAR's own *layout* width (above),
   the digits' rendered *ink* can still spill past that box on the right,
   and overflow: hidden clips paint, not just layout — confirmed via
   getBoundingClientRect() (which matched exactly) vs. actual glyph ink
   (measured via canvas metrics), which didn't: Chromium applies
   letter-spacing after the *last* character too, not just between
   characters, so the layout box ends up a full |letter-spacing| narrower
   than the glyph run actually drawn (~26px of the ~677px "2015" box at
   desktop size) — the mask was clipping a consistent sliver off the
   trailing digit. padding-right gives that room back; the mask's own
   width: max-content grows to match automatically. (The left edge doesn't
   need this: canvas-metric probing showed the leading glyph's ink starts
   comfortably *inside* the box, not past it — there's no matching issue
   to compensate for there.) Scoped to YEAR specifically (not the shared
   .scene-year class NAME also uses) since NAME's plain .reveal-mask is
   never width-constrained and was never at risk of this. */
.reveal-mask--year .scene-year { padding-right: 0.08em; }

@media (max-width: 760px) {
  .scene { padding-left: 20px; padding-right: 20px; padding-top: clamp(120px, 26vh, 220px); padding-bottom: 24px; }
  .scene--intro { padding-top: 0; padding-bottom: clamp(56px, 12vh, 110px); }
  /* Character/crop are untouched on mobile (SequenceCanvas still anchors
     right per config/sequence.ts) — only these text sizes/margins shrink.
     If a future content change makes text and subject compete for space,
     the fix is here (max-width, font-size, line-height) or an extra
     description line — never the image crop/anchor. */
  /* The displayed year figure at 0.4x its previous mobile size (each
     clamp() bound scaled by the same factor: 100/30/180 -> 40/12/72) —
     TITLE/DESCRIPTION and the YEAR->TITLE gap below are untouched, so
     this is purely YEAR reading smaller against them, not a rescale of
     the whole text block. */
  .scene-year { font-size: clamp(40px, 12vw, 72px); }
  .reveal-mask--year { margin-bottom: 32px; }
  /* Follow-up correction to the halving above: the heading-role lines
     (NAME/TITLE, i.e. the "Industrial Design" example) are doubled back
     — 2x of the halved size lands exactly back on their pre-halving
     values (34/58/9.5vw and 32/48/9vw respectively) — while the
     paragraph below only comes up 1.3x (not a full revert) and gets
     narrower (max-width x0.7) rather than wider, per an explicit
     "headings x2, paragraph text x1.3, paragraph block width x0.7"
     request. A further follow-up then bumped the paragraph size again,
     x1.2 on top of that 1.3x value (11.05/2.925vw/14.3 -> 13.26/3.51vw/
     17.16) — width stays at the x0.7 value above, only size moved this
     time. YEAR (above) is still untouched throughout. */
  .scene-year--name { font-size: clamp(34px, 9.5vw, 58px); max-width: 10ch; }
  .scene-title { font-size: clamp(32px, 9vw, 48px); max-width: 14ch; }
  .scene-body { max-width: min(49vw, 294px); font-size: clamp(13.26px, 3.51vw, 17.16px); line-height: 1.3; margin-top: 18px; }
  .scene-body--intro { font-size: clamp(13.26px, 3.51vw, 17.16px); }
  /* The subject occupies more of a narrow frame (crop keeps it anchored
     right, per SequenceCanvas), so text gets a very soft, wide-fade wash
     behind it for legibility — deliberately NOT a hard-edged plate: it
     fades to fully transparent well before the image's right two-thirds.
     This lives on .scene-layer (the single shared parent), NOT on each
     .scene — all 6 .scene elements are stacked absolute/inset:0 on top of
     each other with only their individual TEXT LINES animated to
     opacity:0 when inactive, so a background on .scene itself would paint
     once per scene, compounding into a wash opaque enough to smother the
     one scene that's actually visible underneath it. One backdrop, painted
     once, behind every scene's text — not per-scene. */
  .scene-layer {
    /* More transparent again — same fade shape/extent (0/38/68% stops)
       throughout this whole series, just the peak alpha stepping down
       each pass: 1.0/0.82 -> 0.55/0.42 -> now x0.5 again, 0.275/0.21 —
       per an explicit "more delicate/less noticeable" follow-up. The
       trailing stop was already fully transparent (0) and stays there. */
    background: linear-gradient(90deg, rgba(243, 242, 238, 0.275) 0%, rgba(243, 242, 238, 0.21) 38%, rgba(243, 242, 238, 0) 68%);
  }
}

/* ===================== SECTION 02 — horizontal ===================== */
.horizontal-stage { position: relative; z-index: 1; background: var(--ink); }
.horizontal-pin {
  position: sticky; top: 0; height: 100vh; width: 100vw; overflow: hidden;
  display: flex; align-items: center;
}
.horizontal-track { display: flex; height: 100%; will-change: transform; }
.h-card {
  position: relative; flex: 0 0 100vw; height: 100%;
  display: flex; flex-direction: column; justify-content: flex-end;
  padding: 0 clamp(24px, 6vw, 90px) clamp(48px, 8vh, 100px);
  overflow: hidden; will-change: transform, opacity;
}
.h-card-swatch { position: absolute; inset: 0; z-index: 0; will-change: transform; }
.h-card--dark { color: var(--paper); }
.h-card--light { color: var(--ink); }
.h-card-label {
  position: relative; z-index: 1; font-size: 13px; letter-spacing: 0.14em; text-transform: uppercase;
  opacity: 0.7; margin-bottom: 14px; transition: opacity 240ms var(--ease-out);
}
.h-card-title {
  position: relative; z-index: 1; margin: 0; font-weight: 600;
  font-size: clamp(32px, 6vw, 96px); line-height: 1.04; letter-spacing: -0.01em; max-width: 16ch;
}
/* Client wordmark (X5/NLMK/MTS only — see ProjectCard.logo). Centered and
   independent of the label/title flex flow, which stays bottom-anchored
   (.h-card is justify-content: flex-end) — an absolutely positioned mark
   is the simplest way to give it its own, higher position without
   splitting that flex flow into two alignment groups. Sits at roughly the
   same upper-middle position across all three cards, matching where the
   mark sits in the Figma reference. transform/opacity/filter are all set
   inline every frame by HorizontalProjectCard.update() (the entrance
   reveal) — nothing to declare here for those. pointer-events: none so it
   never intercepts card taps. height: auto keeps each logo's own aspect
   ratio; width is NOT shared — see the per-logo rules below. */
.h-card-logo {
  position: absolute; left: 50%; top: 38%; z-index: 1;
  /* Centering default in case a frame paints before the first JS tick sets
   * this inline (see HorizontalProjects.update()) — the JS value always
   * wins once it runs, this just avoids a one-frame offset before then. */
  transform: translate(-50%, -50%);
  height: auto;
  pointer-events: none;
}
/* Per-logo width, sized for comparable rendered ink AREA rather than a
   shared box size — see the long comment on HorizontalProjectCard's
   constructor for the measured ink-density numbers behind these three
   (X5 ~23% ink, NLMK ~32%, MTS a fully-painted 100% badge). Mobile floors
   prioritize legibility over exact mass-matching — there's little room
   for nuance at that size anyway.

   The three floor/ceiling px values are the Round B mass-balanced sizes,
   scaled up by a uniform 1.2x per a follow-up request to enlarge the
   marks (min and max multiplied by the same factor) — re-verified via a
   fresh ink-weight recompute at the new sizes: ~11% apart at the fluid/
   desktop ceiling, same as Round B.

   The MIDDLE of the fluid range is not a plain `Nvw` per logo, though —
   that was Round B's approach, and re-verifying it here (per this same
   follow-up request) turned up a real gap: X5, NLMK and MTS each hit
   their own ceiling at a DIFFERENT viewport width (X5 ~1394px, NLMK
   ~1364px, MTS was capping out as early as ~1083px), so across the very
   common ~1080-1400px laptop/desktop range MTS sat pinned at full size
   while X5/NLMK were still growing into theirs — visually heavier than
   intended right in the middle of the range, not just "slightly off at
   the extremes". Fixed by giving NLMK and MTS a `calc(Avw + Bpx)` term
   instead of a bare `Nvw`, solved so all three reach their OWN floor and
   OWN ceiling at the exact same two viewport widths as X5 (X5's own
   floor/ceiling crossover points, ~636px and ~1394px) — i.e. all three
   now grow through their fluid range in lockstep rather than each
   saturating on its own schedule. Re-verified: spread across the whole
   1024-1920px band tightened from a 25-46% swing down to ~11-16%.

   A later request asked for NLMK and MTS specifically to read bigger
   still — a deliberate, differentiated correction on top of the mass-
   balanced sizes above (NLMK x1.2, MTS x1.4; X5 untouched), not a
   rebalancing exercise: each modifier's min/coefficient/offset/max were
   all multiplied by that logo's own factor together, which — since
   scaling every term of a `min + coefficient*vw` line by one constant
   leaves the equation's roots unchanged — keeps each logo's own floor-
   and ceiling-crossover viewport widths exactly where they were, so the
   lockstep-growth property above still holds; only the absolute sizes
   (and, deliberately, the relative balance to X5) moved. */
.h-card-logo--x5 { width: clamp(252px, 39.6vw, 552px); }
.h-card-logo--nlmk { width: clamp(202px, calc(30.413vw + 8.064px), 432px); }
.h-card-logo--mts { width: clamp(134px, calc(11.088vw + 63.84px), 218px); }

/* ===================== SECTION 03 — final video =====================
   The <video> itself lives in a fixed layer (.final-bg, built by
   FinalVideoBackground) mounted once at boot, independent of scroll and
   of this section's own flow — see that component's doc comment. This
   section (.final-video) stays visually transparent so the fixed layer
   shows through once HorizontalProjects' exit progress reveals it; only
   the CTA content here has a background of its own (none — text sits
   directly over the video). */
.final-bg {
  position: fixed; inset: 0; width: 100vw; height: 100vh;
  z-index: 0; overflow: hidden;
  pointer-events: none;
  will-change: opacity, clip-path;
}
.final-bg-video {
  position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
  /* Right-weighted, matching every other background visual in the site
     (config/sequence.ts's imagePosition resolves "right center" at every
     breakpoint, via SequenceCanvas's own manual cover-crop math) — but
     NOT a literal `right` (100%), which was tried first and, on
     verification, over-crops: the raw video frame is 1600x900 with the
     subject's own content reaching only to about x=1531 (~95.7% across,
     measured consistently across the whole 10s loop, not just one frame)
     — there's a real ~69px margin of plain background between the
     subject and the true right edge. A literal `right` anchors THAT true
     edge, so on a narrow/tall viewport it crops assuming the subject
     goes all the way to x=1600 — it doesn't, so the crop window lands
     ~70-120px too far right and slices into the left side of the face on
     a phone-width viewport (confirmed by rendering the actual crop and
     comparing it against the source frame). SequenceCanvas.ts solves the
     exact same problem for the image sequence with a measured
     `contentRightFrac` (0.84) that the canvas draw math clamps to,
     instead of anchoring to the literal edge — CSS `object-position` has
     no equivalent clamping, so the closest analog is a tuned percentage
     rather than the `right` keyword: 90% (not 100%) keeps the crop's
     effective right edge a bit short of x=1600, which verified clean
     (full face + comfortable margin, still clearly weighted right, not
     centered) at a phone width (390px), a tablet portrait (768px), and a
     tablet-landscape width (1024px) — the three cases narrow enough that
     this side-crop actually happens (anything at or above the video's
     own ~16:9 stops cropping left/right entirely and crops top/bottom
     instead, where this value has no effect). */
  object-position: 90% center;
}
.final-bg-scrim {
  position: absolute; inset: 0;
  /* Subtle only — brief calls for legible text without over-darkening
     the real footage. */
  background: linear-gradient(180deg, rgba(10,10,12,0.12), rgba(10,10,12,0.5));
}
.final-video {
  position: relative; z-index: 1; height: 100vh; overflow: hidden;
  background: transparent;
}
.final-content {
  position: relative; z-index: 1; height: 100%;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  text-align: center; padding: 0 24px; color: var(--paper);
}
.final-title {
  margin: 0 0 16px; font-weight: 600; color: var(--paper);
  font-size: clamp(30px, 3.6vw, 52px); line-height: 1.1; letter-spacing: -0.02em;
  max-width: 16ch;
}
.final-description {
  margin: 0 auto 36px; font-weight: 400; color: rgba(243, 242, 238, 0.72);
  font-size: clamp(16px, 1.4vw, 20px); line-height: 1.4; max-width: 440px;
}
/*
 * The single, minimal CTA — a real button, not a text link. Back to a
 * large white pill (this went through a blue-gradient, left-aligned
 * round, then explicitly back to "как было до этого" — centered, white,
 * large). transform + background-color only (GPU-cheap, never `all`),
 * ease-out, well under the 300ms UI ceiling. No glow/gradient. Generous
 * padding doubles as a comfortably large touch target on mobile.
 */
.final-cta {
  display: inline-flex; align-items: center; gap: 14px;
  padding: 20px 40px; border-radius: 999px; background: var(--paper); color: var(--ink);
  text-decoration: none; font-weight: 500; font-size: clamp(17px, 2vw, 20px);
  transition: transform 180ms var(--ease-out), background-color 180ms var(--ease-out);
}
.final-cta-arrow { width: 20px; height: 20px; flex: none; transition: transform 180ms var(--ease-out); }
@media (hover: hover) and (pointer: fine) {
  .final-cta:hover { transform: translateY(-2px); background: #ffffff; }
  .final-cta:hover .final-cta-arrow { transform: translateX(4px); }
}
.final-cta:active { transform: translateY(0) scale(0.97); }
.final-cta:focus-visible { outline: 2px solid var(--paper); outline-offset: 6px; }

@media (max-width: 760px) {
  .final-content { padding: 0 24px; }
  .final-title { font-size: clamp(26px, 7.4vw, 34px); max-width: 14ch; }
  .final-description { font-size: 16px; max-width: 32ch; margin-bottom: 28px; }
  .final-cta { padding: 18px 32px; font-size: 16px; }
}

.reveal-line {
  opacity: 0; transform: translateY(22px);
  transition: opacity 700ms var(--ease-out), transform 700ms var(--ease-out);
}
.reveal-line.is-visible { opacity: 1; transform: translateY(0); }

/* ===================== reduced motion ===================== */
@media (prefers-reduced-motion: reduce) {
  .reveal-line { transition: opacity 200ms linear; transform: none; }
  .h-card { transition: none; }
}
html.reduced-motion .sequence-pin,
html.reduced-motion .horizontal-pin { position: sticky; }
html.reduced-motion .scene,
html.reduced-motion .h-card { transition: opacity 200ms linear; }
/* Skip the scroll-scrubbed scale+opacity+clip-path reveal entirely: the
   video layer is simply present once its section is reached, no
   scroll-linked expansion. The <video> itself was never scroll-driven
   (see FinalVideoBackground) — this only concerns the wrapper's own
   entrance treatment. */
html.reduced-motion .final-bg {
  opacity: 1 !important;
  clip-path: none !important;
}
