/* ==========================================================================
   MODERNEST — portfolio design system, v8
   Stylistic reference: bold Swiss/poster editorial — ETH IEA lecture
   posters, Stedelijk Museum, Brut Studio, Bloomberg Businessweek covers.
   Heavy grotesk type, thick rules, hard-edged blocks. Two fonts only:
   Helvetica Condensed for accidenz/display + labels, Helvetica for
   body setting — real Helvetica isn't licensable as a webfont, so
   both stacks lead with the system font (present on Mac/iOS) and
   fall back to free metric-close Google Fonts (Roboto Condensed /
   Roboto) for everyone else. Colour palette lifted from
   fabianburghardt.de/swisscolors/ (mustard/off-white/orange/blue).
   Navigation logic reference: gailycurl.com — minimum clicks to a case
   study, no extra screens.
   ========================================================================== */

:root {
  --paper: #fbfbfb;
  --paper-2: #f2f1ec;
  --ink: #211e1d;
  --ink-soft: #66625d;
  --line: rgba(33, 30, 29, 0.14);
  --line-strong: rgba(33, 30, 29, 0.45);
  --rule: 3px;
  --accent: #c7943e;
  --accent-2: #2677bb;
  --accent-3: #f67944;
  --focus: var(--accent-2);

  --font-display: "Helvetica Neue Condensed", "Arial Narrow", "Roboto Condensed", sans-serif;
  --font-mono: "Helvetica Neue Condensed", "Arial Narrow", "Roboto Condensed", sans-serif;
  --font-body: "Helvetica Neue", Helvetica, Arial, "Roboto", sans-serif;

  --gutter: clamp(1.25rem, 4vw, 3rem);
  --max: 1080px;
}

* { box-sizing: border-box; }

html { scroll-behavior: smooth; }

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: 17px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
}

a { color: inherit; }
img { max-width: 100%; display: block; }

.wrap { max-width: var(--max); margin: 0 auto; padding: 0 var(--gutter); }

/* ---------- header ---------- */

.site-header {
  position: sticky;
  top: 0;
  z-index: 40;
  background: var(--paper);
  display: flex;
  /* bottom-align logo and nav words on the same line — center would
     put the logo slightly lower than the nav text, since the nav's
     line-height adds space the logo image doesn't have */
  align-items: flex-end;
  justify-content: space-between;
  padding: 1.5rem var(--gutter);
  border-bottom: var(--rule) solid var(--ink);
}

.wordmark { display: block; }

.wordmark img {
  height: clamp(18px, 2.2vw, 24px);
  width: auto;
  /* two separate corrections doing two separate jobs — don't collapse
     them into one, and don't drop either:
     1. `margin-bottom: -5px` controls LAYOUT, not paint: .wordmark has
        no padding/border of its own, so this negative margin collapses
        through it, shrinking how much cross-size .wordmark contributes
        to the header's flex line — matching the ~5px (2px padding-
        bottom + 3px transparent border-bottom) that .nav-trigger
        contributes but the logo doesn't. Without it, .wordmark becomes
        the tallest flex item instead of .nav-trigger, and the *whole*
        header grows ~5px taller (confirmed directly: removing this
        took .site-header from 70px to 75px, shifting Works/About down
        with it, even though neither was touched).
     2. `position: relative; top: -3.7px` controls PAINT, not layout:
        it nudges the logo's actual rendered position to match the nav
        text's real glyph baseline — measured with
        Range().getBoundingClientRect() on the trigger's text node,
        not the trigger <a>'s own (padded-out) box. This is needed
        *in addition to* #1 because margin-bottom's effect on the
        logo's own paint position is not linear with its layout effect
        — sweeping margin-bottom from 0 to -8px changes .site-header's
        height every step, but the logo's own rendered bottom edge
        stays frozen at one value across that whole range (it only
        starts moving past roughly -8.8px) — so margin-bottom alone
        cannot hit the correct paint position without overshooting the
        layout height in the process. `top` sidesteps that entirely
        since it never touches layout.
     If the trigger's font-size/padding/border ever change, re-verify
     both numbers with getBoundingClientRect()/a text Range — don't
     retune by eye, the relationship here is not intuitive. */
  margin-bottom: -5px;
  position: relative;
  top: -3.7px;
}

.site-nav {
  display: flex;
  gap: 2rem;
  font-size: 0.95rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.01em;
  line-height: 1;
}

/* ---------- nav dropdowns ----------
   Hover opens the panel (desktop); clicking the trigger word itself
   is a real link to the full Works/About page. The panel is anchored
   at the trigger's own top edge (not below it) and includes a
   duplicate of the label as its first line, so the open state reads
   as one grouped box (label + its items together) rather than a
   label floating above a separate panel. Panels are never
   click-toggled — :focus-within covers keyboard access. */

.nav-dropdown { position: relative; }

.nav-trigger {
  cursor: pointer;
  text-decoration: none;
  color: inherit;
  border-bottom: 3px solid transparent;
  padding-bottom: 2px;
  /* border-bottom-color and visibility both flip on the exact same
     `:hover`/`:focus-within` condition below — without a delay on
     visibility, both changes land in the same style pass, so the
     border-darken/"×" hover feedback is computed but never actually
     painted before the trigger vanishes (replaced by the panel's
     duplicate label). The delay matches the panel's own 0.12s fade
     (`.dropdown-panel`'s `transition: opacity 0.12s ease`), so the
     trigger's hover state is visible for a beat while the panel fades
     in, then hands off right as the panel becomes solid — and, in
     reverse, the trigger reappearing is held off until the panel has
     mostly faded out too, avoiding a gap where neither is visible. */
  transition: border-bottom-color 0.15s ease, visibility 0s linear 0.12s;
}

.nav-trigger::after {
  content: "";
  display: inline-block;
  margin-left: 0.4em;
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 0.85em;
  color: var(--ink);
}
.nav-dropdown:hover .nav-trigger::after,
.nav-dropdown:focus-within .nav-trigger::after { content: "×"; }

.nav-dropdown:hover .nav-trigger,
.nav-dropdown:focus-within .nav-trigger { border-bottom-color: var(--ink); }

/* the box covers the trigger once open, so hide the plain in-flow
   label underneath it — the duplicate .dropdown-panel-label takes
   over both the look and the click target */
.nav-dropdown:hover .nav-trigger,
.nav-dropdown:focus-within .nav-trigger { visibility: hidden; }

/* mouse dead-zone, Works only: when About's panel is open, its box is
   wide enough to physically sit on top of Works' own trigger (About's
   panel is right-anchored to About's trigger and extends left past
   it), so the mouse can't reach Works to switch panels — it just hits
   About's panel again. This invisible rectangle lives inside Works'
   own .nav-dropdown (so hovering it counts as hovering Works, opening
   Works' panel and, since the mouse is no longer over any part of
   About's DOM subtree, closing About) and sits above the panel
   (z-index higher than .dropdown-panel's 30) so it wins hit-testing
   in that overlap. It only covers the *top* of the overlap (roughly
   the header row), not the full panel height, and stops short of
   About's own trigger/label/links so About itself stays separately
   hoverable/clickable (verified via getBoundingClientRect — no
   overlap). It's a real `<a href="works.html">` (same pattern as
   `.dropdown-panel-label`'s duplicate-link trick), not a plain
   `<span>` — it deliberately does overlap Works' *own* trigger box
   too, so it must still navigate correctly when clicked there; always
   `pointer-events: auto` (no conditional/`:has()` needed — tried, but
   the moment it activates it would remove the very hover condition it
   depends on, an infinite-loop risk). Plain rectangle, no clip-path/
   polygon needed. */
.hover-bridge {
  display: block;
  position: absolute;
  top: 0;
  left: -64px;
  width: 156px;
  height: 2.4rem;
  z-index: 35;
}

.dropdown-panel {
  position: absolute;
  top: 0;
  /* the panel's border extends 0.5ch further right than the trigger's
     own edge (negative right + matching padding-right below) so text
     gets a little breathing room from the border without the label's
     on-screen position moving — see the top padding note */
  right: -0.5ch;
  min-width: 220px;
  background: var(--paper);
  border: 2px solid var(--ink);
  /* zero top padding on purpose: the label below needs to land
     exactly where the trigger word sat in the header (only the 2px
     border is between them), so the word doesn't visibly jump when
     the panel opens — the small right padding is just breathing room
     before the border (compensated by the negative `right` above),
     bottom/left padding is for breathing room around the items
     underneath the label */
  padding: 0 0.5ch 1.1rem 1.2rem;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  text-align: right;
  gap: 0.7rem;
  box-shadow: 7px 7px 0 var(--ink);
  z-index: 30;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.12s ease;
}

.nav-dropdown:hover .dropdown-panel,
.nav-dropdown:focus-within .dropdown-panel {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
}

/* 2026-07-22: nav hover feedback simplified to underline-only, no
   colour change — every `:hover`/open state in this dropdown system
   used to also switch text to `--accent` (mustard); removed per
   direct request ("уберем выделение цветом и оставим только
   выделение подчеркиванием"). The permanent divider under the label
   (`border-bottom: 2px solid var(--ink)`, always visible, separating
   the label from the items below) is gone too ("разделительные линии
   тоже убери") — the label now uses the SAME transparent-until-hover
   border pattern as the links below it, which costs nothing
   functionally since the label is never on screen except while its
   own `.nav-dropdown` is already hovered/focus-within (i.e. it reads
   as "active" the instant it's visible, same as before). */
.dropdown-panel > a.dropdown-panel-label {
  width: 100%;
  font-size: 0.95rem;
  font-weight: 700;
  text-transform: uppercase;
  text-decoration: none;
  color: var(--ink);
  padding-bottom: 0.6rem;
  margin-bottom: 0.2rem;
  border-bottom: 2px solid transparent;
}

/* tied to the same hover/focus-within condition that opens the panel
   (not a separate re-hover directly on the label) — see the comment
   above this block for why. */
.nav-dropdown:hover .dropdown-panel-label,
.nav-dropdown:focus-within .dropdown-panel-label {
  border-bottom-color: var(--ink);
}

.dropdown-panel > a {
  text-decoration: none;
  font-size: 0.95rem;
  font-weight: 700;
  text-transform: uppercase;
  color: var(--ink);
  border-bottom: 2px solid transparent;
  padding-bottom: 1px;
  width: fit-content;
}
.dropdown-panel > a:hover { border-bottom-color: var(--ink); }

.nav-subdropdown { width: 100%; }

.nav-subdropdown > summary {
  cursor: pointer;
  list-style: none;
  font-size: 0.95rem;
  font-weight: 700;
  text-transform: uppercase;
  text-align: right;
  border-bottom: 2px solid transparent;
  padding-bottom: 1px;
  width: fit-content;
  margin-left: auto;
  transition: border-bottom-color 0.15s ease;
}
/* unlike the plain <a> links in the panel (which get their hover
   style from `.dropdown-panel > a:hover`), Info/Contacts are
   <summary> elements — that selector doesn't match them, so they had
   cursor:pointer but no other hover feedback at all */
.nav-subdropdown > summary:hover { border-bottom-color: var(--ink); }
.nav-subdropdown > summary::-webkit-details-marker { display: none; }

.subdropdown-body { padding: 0.6rem 0 0.1rem; text-align: right; }
.subdropdown-body p { margin: 0 0 0 auto; font-family: var(--font-body); font-size: 0.9rem; font-weight: 400; text-transform: none; color: var(--ink-soft); }
.subdropdown-body a { color: var(--ink-soft); border-bottom: 1px solid transparent; text-decoration: none; }
.subdropdown-body a:hover { border-bottom-color: var(--ink-soft); }

/* Info/Contacts bodies read left-to-right (running prose/contact
   lines), unlike the rest of the right-aligned dropdown chrome
   around them */
.subdropdown-body-left { text-align: left; max-width: 300px; }
.subdropdown-body-left p { margin: 0 0 0.75rem; }
.subdropdown-body-left p:last-child { margin-bottom: 0; }

/* ---------- hero: marquee frame ----------
   The old static two-line headline became a scrolling marquee that
   wraps the cycling project visual on all four sides, poster-style
   (ref: rotated-edge Swiss/type posters) — not just top/bottom. The
   same big headline type size is kept, just in motion. */

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* The hero photo stays pinned in place while the page scrolls, and
   the work index rises up from below to cover it ("картинка...
   остается как бы на своем месте, а элементы снизу поднимались наверх
   и перекрывали картинку") — .hero itself is left in normal flow
   purely as a spacer (min-height matching the frame's own height, via
   the shared --hero-height custom property so the two can't drift out
   of sync), while .hero-marquee-frame is taken out of flow and fixed
   to the viewport just below the sticky site header. .work-index (and
   everything after it) is given a higher stacking order below so it
   paints over the fixed frame as it scrolls up — see that rule for
   the other half of this.

   The marquee text (top/bottom bands) is NOT part of the pinned
   frame — a direct follow-up request: "я хочу чтобы бегущая строка
   поднималась вместе со всем страницей" (the marquee should rise
   together with the whole page, not stay frozen with the image).
   .hero-marquee-overlay is a separate sibling, position:absolute
   within .hero (normal flow, NOT fixed) — it scrolls away normally
   with the rest of the page while the pinned image sits fixed
   beneath it, so the text (and its scrim, see below) visibly rises
   and clears the frame as you scroll, revealing the plain pinned
   photo underneath, before the work index later rises to cover that
   too. position:relative on .hero gives the overlay its containing
   block. */
.hero {
  /* the 260px floor used to dominate at mobile widths (30vw of a
     ~375px phone is only ~110px), squashing the frame into a
     noticeably more square shape there than on desktop — direct
     follow-up: "сделай чтобы на мобильной версии формат хироу банера
     был такой же длинный" (make the mobile hero banner's format just
     as long/elongated as desktop's). Lowered the floor so the 30vw
     term keeps governing all the way down to real mobile widths
     instead of the floor taking over — width:height now stays close
     to ~3.3:1 (near the banner photos' own native ~3.43:1) at every
     viewport, not just desktop ones. */
  --hero-height: clamp(110px, 30vw, 460px);
  padding: 0;
  position: relative;
}

/* min-height only applies on index.html's marquee hero — .hero is
   shared with 404.html's plain static heading, which must keep
   sizing itself from its own content, not be forced to this height */
.hero:has(.hero-marquee-frame) {
  min-height: var(--hero-height);
}

.hero-marquee-frame {
  position: fixed;
  top: var(--header-height, 70px);
  left: 0;
  right: 0;
  height: var(--hero-height);
  overflow: hidden;
  z-index: 1;
}

.hero-marquee-overlay {
  position: absolute;
  inset: 0;
  overflow: hidden;
  z-index: 2;
  pointer-events: none;
}

/* Permanent dark scrim behind the marquee text, top and bottom —
   guarantees the white text stays readable regardless of what's
   underneath, rather than relying on a per-banner brightness guess
   (tried first, dropped: "белые буквы не всегда хорошо читаются...
   добавить тенюшки сверху и снизу под текст, чтобы даже на светлых
   картинках все было хорошо видно"). Lower z-index than
   .hero-marquee itself (2) so it sits behind the text, not over it. */
.hero-marquee-overlay::before,
.hero-marquee-overlay::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  height: 32%;
  z-index: 1;
}
.hero-marquee-overlay::before {
  top: 0;
  background: linear-gradient(to bottom, rgba(33, 30, 29, 0.65), transparent);
}
.hero-marquee-overlay::after {
  bottom: 0;
  background: linear-gradient(to top, rgba(33, 30, 29, 0.65), transparent);
}

.hero-marquee {
  position: absolute;
  overflow: hidden;
  white-space: nowrap;
  display: flex;
  align-items: center;
  z-index: 2;
}

.hero-marquee-top,
.hero-marquee-bottom { left: 0; right: 0; padding: 0 1rem; }
.hero-marquee-top { top: 0.9rem; }
.hero-marquee-bottom { bottom: 0.9rem; }

.hero-marquee-track {
  display: inline-flex;
  align-items: center;
  width: max-content;
  animation: hero-marquee-scroll 14s linear infinite;
  /* stays paused until nav.js has finished duplicating content and
     set the real --marquee-shift distance — starting immediately
     would animate against the un-duplicated single copy and the
     50% fallback, causing a visible gap right at page load */
  animation-play-state: paused;
}

.hero-marquee-track-reverse { animation-direction: reverse; }

.hero-marquee-track > * {
  flex-shrink: 0;
  padding: 0;
  font-family: var(--font-mono);
  font-weight: 700;
  text-transform: uppercase;
  font-size: 0.85rem;
  letter-spacing: 0.08em;
  color: var(--paper);
}

.marquee-word {
  color: var(--paper);
  opacity: 0.65;
  text-decoration: none;
  text-underline-offset: 3px;
  transition: opacity 0.2s ease;
}
.marquee-word.is-active { opacity: 1; text-decoration: underline; }

.hero-marquee-center {
  position: absolute;
  inset: 0;
  /* dark placeholder, not the usual light --paper-2 — the white
     marquee text overlaid on top of this needs real contrast until
     actual photography (which will supply its own) replaces it.
     Stays visible as the base layer beneath the banner photos below
     — if a project has no banner file yet, this is what still shows
     through. */
  background: var(--ink);
}

/* cycling project banners, layered on top of the dark placeholder
   above — see nav.js for the rotation logic. Two stacked <img>s
   cross-fade between whichever images/hero-banners/<slug>.jpg files
   actually exist; the white marquee text needs real contrast, so
   keep these photos on the darker/higher-contrast side. Fade duration
   (0.25s) is deliberately shorter than the rotation interval nav.js
   uses (0.6s, per direct request — "меняться проекты должны быстро,
   раз в 0.6 секунд") so each cross-fade fully completes with room to
   spare before the next one starts; it was 0.8s when the rotation was
   a slower 4s, which would now overlap fades if left unchanged. */
.hero-marquee-banner {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity 0.25s ease;
}
.hero-marquee-banner.is-visible { opacity: 1; }

@keyframes hero-marquee-scroll {
  from { transform: translateX(0); }
  to { transform: translateX(calc(-1 * var(--marquee-shift, 50%))); }
}

@media (max-width: 640px) {
  .site-header { flex-wrap: wrap; gap: 0.75rem; }
  .dropdown-panel { min-width: 180px; right: -0.5rem; }
  /* smaller nav text on mobile — at full size, "Works"/"About" plus
     the dropdown's own links/labels wrapped onto two lines far too
     often ("слишком часто там в две строки то, что должно быть в
     одну"); this shrinks the whole nav system together rather than
     each piece separately, so it stays visually consistent */
  .site-nav { font-size: 0.8rem; gap: 1.25rem; }
  .dropdown-panel > a,
  .dropdown-panel-label,
  .nav-subdropdown > summary { font-size: 0.82rem; }
  /* --hero-height is NOT overridden here on purpose — the mobile hero
     used to run smaller (60vw) than desktop's clamp(260px, 32vw,
     460px); direct request to match desktop's full size instead
     ("hero banner в мобильной версии показывай тоже в полный размер
     как и в десктопной") */
  .hero-marquee-track > * { font-size: 0.72rem; }
}

/* ---------- work index: frame grid, hover-to-reveal ---------- */

/* position/z-index/background: rises above the fixed .hero-marquee-frame
   as the page scrolls (see the note on .hero above) — harmless on pages
   without that fixed frame, this section just paints normally there */
.work-index { position: relative; z-index: 2; background: var(--paper); padding: 0 0 4rem; }

/* sticks in place once scroll brings it up to the site header, instead
   of continuing to scroll away with the rest of the grid — direct
   request: "плашка с selected work, когда доходила до nav оставалась
   на месте и не уходила с экрана". z-index:1 is only relative to
   .work-index's own stacking context (that section is already
   z-index:2 on the page, see the pinned-hero note above) — enough to
   beat its sibling .work-list, which has no z-index of its own, so
   the grid doesn't scroll up over the now-stuck bar. */
.work-index-head {
  position: sticky;
  top: var(--header-height, 70px);
  z-index: 1;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  flex-wrap: wrap;
  row-gap: 0.5rem;
  background: var(--ink);
  color: var(--paper);
  padding: 1rem var(--gutter);
  margin-bottom: 2px;
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 0.85rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}

.work-filter { display: flex; gap: 1.1rem; }

.work-filter a {
  color: var(--paper);
  opacity: 0.65;
  text-decoration: none;
  text-underline-offset: 3px;
  transition: opacity 0.15s ease;
}
.work-filter a:hover { opacity: 1; }
.work-filter a.is-active { opacity: 1; text-decoration: underline; }

.work-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 2px;
  background: var(--ink);
}

/* works.html's full index only (not index.html's 4-entry teaser,
   which stays a plain 2-column grid) — a 4-column grid where a normal
   project spans 2 (so 2 fit per row) and a single-poster project
   spans 1 ("half"), direct request 2026-07-24: row 1 is two full
   tiles, row 2 is half/full/half, row 3 is two full tiles, row 4 is
   half/full/half. This is a real grid-column span, not the internal
   50/50 split .work-entry-poster-pair does inside one cell — each
   .is-half tile is its own independent, genuinely narrower grid item,
   sitting next to unrelated projects rather than paired with another
   poster in the same cell. */
.work-list-full {
  grid-template-columns: repeat(4, 1fr);
}
.work-list-full > li { grid-column: span 2; }
.work-list-full > li.is-half { grid-column: span 1; }

/* a half-width tile still inheriting the normal 4:3 aspect-ratio just
   becomes a smaller 4:3 box — squatter than the poster it's showing,
   so a cover-crop was chopping close to half the poster away
   vertically ("ты в половину отрезал по вертикали"). At half the grid
   width, an aspect-ratio of 2:3 makes its height come out equal to a
   same-row full tile's height (full: width 2u, height 2u*3/4 = 1.5u;
   half: width 1u, height 1u*3/2 = 1.5u — same 1.5u either way), AND
   it's much closer to the poster's own real proportions, so cropping
   is minimal instead of severe. */
.work-list-full > li.is-half .work-entry-link,
.work-list-full > li.is-half .work-entry-row {
  aspect-ratio: 2 / 3;
}

/* Mobile: don't fall back to full-width/4:3 for the half tiles — that
   reintroduces the same heavy vertical crop the 2:3 fix above was for
   (direct follow-up: "а в мобилке тоже не обрезай, а ставь рядом", no
   cropping on mobile either, put them side by side). Instead, mobile
   uses its own 2-column grid: a normal tile spans both columns (full-
   width row), a half tile spans 1 — and grid-auto-flow: dense lets a
   later half tile jump up to fill the gap left by an earlier one, so
   the four poster tiles end up paired into two side-by-side rows
   (Mamy i Papy+Freedom Poster, Be Real Poster+Jazz Poster) even though
   they're not adjacent in the underlying list order. Keeps the same
   2:3 aspect-ratio as desktop — no reason to change it, a 2-column
   mobile row has the same width/height relationship as a 4-column
   desktop one (half : full = 1 : 2 either way). */
@media (max-width: 640px) {
  /* .work-list.work-list-full (two classes) — a later, generic
     ".work-list { grid-template-columns: 1fr }" mobile rule further
     down the file has equal specificity to a bare ".work-list-full"
     and would win on source order alone; the extra class guarantees
     this one wins regardless of where either rule sits in the file. */
  .work-list.work-list-full {
    grid-template-columns: repeat(2, 1fr);
    grid-auto-flow: dense;
  }
  .work-list-full > li { grid-column: span 2; }
  .work-list-full > li.is-half { grid-column: span 1; }
}

/* present in the DOM (so nav.js's hero-banner rotation can see them
   as real projects) but not part of the visible teaser grid — see the
   "hero rotation" note in nav.js for why this replaced a fetch-based
   approach. */
.work-list-hero-only { display: none; }

/* two single-poster projects (no other carrier photography, just the
   poster itself) paired into ONE grid cell instead of each getting a
   whole tile — the cell is split down the middle, so each half lands
   at exactly the poster's own 2:3 ratio: half of a normal 4:3 tile's
   width, full height — (W/2) / 0.75W = 0.667. An earlier attempt
   solved this with `grid-row: span 2` on the <li> instead, using two
   neighbouring rows' height to reach the same 2:3 box — worked on
   desktop but broke on mobile, where the grid collapses to a single
   column and there's no second row to borrow height from ("в мобилке
   не работает"). This version doesn't touch the outer grid at all —
   the pair wrapper carries its own self-contained 4:3 aspect-ratio
   and splits it via flex, so it renders identically regardless of how
   many columns the outer .work-list grid currently has. */
.work-entry-poster-pair {
  position: relative;
  display: flex;
  aspect-ratio: 4 / 3;
  overflow: hidden;
  /* when only one poster fills the cell (no pair partner yet, e.g.
     Be Real Poster), the other half is plain white rather than the
     grid's own dark background showing through — reads as "empty,
     waiting for a partner" instead of blending into the grid lines */
  background: var(--paper);
}
.work-entry-poster-pair .work-entry-link {
  aspect-ratio: auto;
  width: 50%;
  height: 100%;
  flex: none;
}

.work-entry-link,
.work-entry-row {
  position: relative;
  display: block;
  aspect-ratio: 4 / 3;
  overflow: hidden;
  cursor: pointer;
  text-decoration: none;
  color: inherit;
}

.work-entry-image {
  position: absolute;
  inset: 0;
  background: var(--paper-2);
  transition: opacity 0.15s ease;
}

.work-entry-image img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.work-entry-row .work-entry-image { filter: grayscale(1); opacity: 0.55; }

/* 90% of --ink over the image, not a solid fill (2026-07-22, bumped
   from an initial 80% same day) — the image stays visible underneath,
   dimmed rather than replaced ("не прям сплошной цвет, а 80%/90%...
   поверх картинки"). --ink is #211e1d = rgb(33, 30, 29); written as
   rgba() directly since CSS can't apply an alpha to a var() color
   without color-mix(). The old `.work-entry-image { opacity: 0 }`
   hover rule is gone — the image no longer disappears, it just shows
   through the dark layer. */
.work-entry-overlay {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: 0.4rem;
  padding: 1.25rem;
  background: rgba(33, 30, 29, 0.9);
  color: var(--paper);
  opacity: 0;
  transition: opacity 0.15s ease;
}

.work-entry-link:hover .work-entry-overlay,
.work-entry-row:hover .work-entry-overlay { opacity: 1; }

.work-entry-tag {
  position: absolute;
  top: 1.25rem;
  left: 1.25rem;
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--paper);
  opacity: 0.7;
}

/* visually hidden (not display:none, so screen readers/SEO still get
   the project name), same clip technique as .sr-only — 2026-07-22:
   the visible title moved to a cursor-following label instead (see
   .cursor-title), sagmeister.com-style; nav.js reads this element's
   text + computed font-size to build that label, so the font rules
   here still matter even though the box itself is invisible. */
.work-entry-title {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
  font-family: var(--font-display);
  font-weight: 700;
  text-transform: uppercase;
  font-size: clamp(1.1rem, 1.8vw, 1.4rem);
  line-height: 1.15;
  letter-spacing: -0.01em;
  color: var(--paper);
}

/* the cursor-following project title (2026-07-22) — generic/shared
   class name (not work-entry-specific) since it's used in two places:
   `.work-entry-link`/`.work-entry-row` hover (nav.js creates its own
   instance, font-size copied from the hovered entry's own hidden
   `.work-entry-title` so "the font size stays the same" as before
   this was a cursor-follower) and the homepage hero banner hover
   (its own instance, same class, showing whichever project is
   currently displayed — see the hero banner rotation note). Each
   feature creates and owns its own element/instance; they're never
   shown at the same time so there's no need for one shared singleton. */
.cursor-title {
  position: fixed;
  top: 0;
  left: 0;
  transform: translate(-50%, calc(-100% - 18px));
  pointer-events: none;
  z-index: 60;
  font-family: var(--font-display);
  font-weight: 700;
  text-transform: uppercase;
  line-height: 1.15;
  letter-spacing: -0.01em;
  color: var(--paper);
  white-space: nowrap;
  opacity: 0;
  transition: opacity 0.15s ease;
}
.cursor-title.is-visible { opacity: 1; }

.work-entry-desc {
  color: var(--paper);
  opacity: 0.75;
  margin: 0;
  font-size: 0.88rem;
  font-family: var(--font-body);
}

@media (max-width: 640px) {
  .work-list { grid-template-columns: 1fr; }
  /* the 6-word category filter (All/Branding/Posters/Typography/
     Culture/Horeca) wrapped onto two lines at the full 0.85rem size —
     same "буквы... в навигации и кнопки" complaint as the header nav */
  .work-index-head span { font-size: 0.78rem; }
  .work-filter { gap: 0.7rem; }
  .work-filter a { font-size: 0.72rem; }
}

/* ---------- case study page ----------
   A long-form narrative, not an end-of-page image dump: short beats
   of body copy alternating with full-bleed figures and 2-up pairs,
   so scrolling has rhythm instead of "wall of text, wall of thumbs".
   No index number and no single dominating hero image — the page
   opens straight into the title + lede, and the first photo arrives
   as part of the story. Flat presentation throughout, by explicit
   request — **no borders/rules, no box-shadows, no caption overlays
   on any image**; images sit directly on the page background with
   nothing framing them. If you're tempted to add a border/shadow/
   caption back "for consistency" with other parts of the site, don't
   — this page was deliberately de-brutalist-ed, unlike the rest of
   Modernest's thick-rule/hard-shadow system. */

/* compact title bar — hidden until the full-size .case-head has
   scrolled past, then appears pinned just below the (sticky) site
   header. `top` reads a `--header-height` custom property that
   nav.js measures from the real header at runtime (rather than a
   hardcoded 70px) — on mobile the header can wrap to a taller height
   (see `.site-header { flex-wrap: wrap }` below), and a stale
   hardcoded value left a few px of page content visible in the gap
   between the header and this bar. Falls back to 70px if JS hasn't
   run yet (matches the header's actual desktop height, so there's no
   flash of a wrong value).
   `position: fixed`, not `sticky` (2026-07-22 fix) — `sticky` still
   reserves its normal box in the document flow even while
   `visibility: hidden`, which left an invisible ~40px gap between the
   real header and whatever comes right after it (the opening
   text/photo pairing) on every case-study page, at the very top of
   the page, before any scrolling happened. `fixed` needs the exact
   same `top` value to stay visually pinned in the same spot once
   shown, but never occupies flow space, closing that gap for good —
   don't switch this back to `sticky`, that's what caused it. */
.case-head-sticky {
  position: fixed;
  top: var(--header-height, 70px);
  left: 0;
  right: 0;
  z-index: 20;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  background: var(--paper);
  border-bottom: 1px solid var(--line-strong);
  padding: 0.7rem var(--gutter);
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.2s ease;
}
.case-head-sticky.is-visible { opacity: 1; visibility: visible; }
.case-head-sticky-title {
  font-family: var(--font-display);
  font-weight: 700;
  text-transform: uppercase;
  font-size: 1rem;
  letter-spacing: -0.01em;
}
/* same size/weight as .case-meta below, right-set instead of left —
   mirrors the full-size case-head, which is meta (top-left) + title;
   the compact bar reverses to title (left) + meta (right) since both
   need to stay reachable within one thin row */
.case-head-sticky-meta {
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 0.82rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--ink-soft);
  text-align: right;
}

.case-head { padding: clamp(2rem, 6vw, 3rem) var(--gutter) 1rem; }

/* meta sits above the title, pinned top-left, right under the header
   — deliberately NOT tight against the h1 below it ("не надо
   прибивать его к тайтлу"): a large margin-bottom reads as its own
   element near the header, not a subtitle stuck to the title */
.case-meta {
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 0.82rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--ink-soft);
  margin: 0 0 3rem;
}

.case-head h1 {
  font-family: var(--font-display);
  font-weight: 700;
  text-transform: uppercase;
  font-size: clamp(2rem, 5.5vw, 3.2rem);
  line-height: 1;
  margin: 0;
  letter-spacing: -0.02em;
}

/* lede — larger than running body copy, its own beat right under the
   title before any image interrupts. Padding is symmetric (same
   token top and bottom, shared with .case-body below) so the text
   sits centred in its own block instead of reading top- or
   bottom-heavy ("текст выглядел посередине"). */
.case-lede {
  padding: 1.5rem var(--gutter);
  max-width: 760px;
  font-family: var(--font-body);
  font-size: clamp(1.1rem, 2.1vw, 1.3rem);
  line-height: 1.5;
  color: var(--ink);
}

/* same 1.5rem top and bottom as .case-lede — one shared token used
   symmetrically, not two different arbitrary numbers */
.case-body { padding: 1.5rem var(--gutter); max-width: 680px; }

.case-body p { color: var(--ink-soft); margin: 0 0 1rem; }
.case-body p:last-child { margin-bottom: 0; }

.case-body .placeholder-note {
  font-family: var(--font-mono);
  font-size: 0.76rem;
  color: var(--ink);
  background: var(--paper-2);
  padding: 0.6rem 0.8rem;
  margin: 0 0 1rem;
  border: 1px dashed var(--line-strong);
}

/* ---------- shared half/full-width content block ----------
   `.case-half-item` is the ONE primitive used for every block in a
   case study that needs to sit at exactly half or full page width —
   text just as much as a photo or video, no separate logic for each.
   It's always exactly 50% width (`.is-left`/`.is-right` pick which
   edge it hugs, via margin-auto on the other side) or `.is-full` for
   100% — and it behaves identically whether items stack vertically
   (`.case-stack-half`, below) or sit side by side in one row
   (`.case-split-half`, used for the opening text+photo/video pairing).
   Neither container puts a gap between items — the boundary between
   two half-width items is always the exact centre line, never off by
   a gap/padding amount. This replaced an earlier version
   (`.case-intro`) that paired the intro text with the lookbook photo
   using a CSS grid with a `gap` between the two columns — that gap
   meant neither column actually reached the true 50% mark, so the
   photo visibly fell short of "half the page" (reported directly:
   "модель не ровно половину занимает"). Any breathing room near the
   seam now comes from padding INSIDE an item's own content (see
   `.case-split-half .case-body-text` below), never from spacing on
   the container — that's what makes the split exact, not approximate. */
.case-half-item { width: 50%; }
.case-half-item.is-left { margin-right: auto; }
.case-half-item.is-right { margin-left: auto; }
.case-half-item.is-full { width: 100%; margin: 0; }

.case-half-item img { width: 100%; display: block; }
.case-half-item .video-frame { position: relative; overflow: hidden; }
.case-half-item .video-frame iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  display: block;
}

/* side-by-side pairing (row) — the opening text+photo/video beat */
.case-split-half {
  display: flex;
  align-items: stretch;
}
.case-split-half .case-body-text {
  padding-left: var(--gutter);
  padding-right: clamp(1.5rem, 4vw, 3rem);
}
/* top padding cut way down from the standalone `.case-head` default
   (clamp(2rem, 6vw, 3rem)) — the user asked for the meta/title text to
   sit almost flush under the header ("подними надпись... почти до
   самого края снизу от нав"), not just the photo/video beside it. */
.case-split-half .case-head { padding: clamp(0.75rem, 2vw, 1.25rem) 0 1rem; }
.case-split-half .case-lede { padding-left: 0; padding-right: 0; max-width: none; }
.case-split-half .case-body { padding-left: 0; padding-right: 0; max-width: none; }

/* width comes from `.case-half-item` (50%/100%, whichever applies) —
   don't set width here too, it's the same specificity and whichever
   rule is declared later would silently win and break the split */
.case-hero-photo {
  height: 100%;
  object-fit: cover;
  display: block;
}

.case-mobile-video-note { display: none; }

@media (max-width: 768px) {
  .case-split-half { flex-direction: column; }
  .case-half-item { width: 100%; margin: 0; }
  /* was a flat `70vw` height + object-fit: cover inherited from
     desktop — cropped the opening photo hard on narrow viewports
     whenever its real aspect ratio didn't match 70vw (reported on
     both Prostor and Coffee Mini: "слишком обрезана"/"слишком
     обрезал"). height: auto lets it render at its own natural
     aspect ratio, full width, nothing cropped. */
  .case-hero-photo { height: auto; }
  /* Mamy i Papy only: on mobile the text/video pairing stacks into one
     column, so the "creative coding" note (which sits fine right next
     to the video on desktop) needs to move to sit AFTER the video
     instead of before it, with a bit of breathing room to read as its
     own caption rather than part of the intro paragraph flow — desktop
     is untouched, it still uses the original in-column paragraph. */
  .case-desktop-video-note { display: none; }
  .case-mobile-video-note {
    display: block;
    width: 100%;
    padding: 1.5rem var(--gutter) 0;
    font-family: var(--font-body);
    font-size: 1rem;
    line-height: 1.5;
    color: var(--ink-soft);
  }
}

/* ---------- Instagram-post scroll carousel ----------
   v1 pinned the window (`position: sticky` + a 250vh wrapper) and let
   scroll drive it through the *entire* strip — the user explicitly
   rejected that: it reads as "scroll down, get stuck scrubbing
   sideways, then scroll down again", not a continuous downward
   scroll ("не так что ты дошел потом пролистываешь вправо а потом
   опять вниз, а что ты всегда листаешь вниз"). Rebuilt as a plain,
   non-pinned section that the page scrolls straight through normally
   — nav.js just adds a continuous horizontal parallax drift as the
   section passes through the viewport ("небольшая анимация в сторону
   по ходу скролла"), not a scroll-jack.
   v6 made it full-screen height, which the user then said was too
   big ("очень крупно"); height is now tuned so **4.5 cards** are
   visible regardless of viewport width — one card (post-padding) is
   320×500px, aspect 0.64, and height scales with viewport width via
   `vw` so the card count stays constant: height = viewportWidth /
   (4.5 × 0.64) ≈ 34.7vw. Recompute this if the cards' own aspect
   ratio changes, don't guess.
   v8 (2026-07-22): `.case-scroll-strip` used to be a SINGLE flattened
   composite image (`posts.png`) — the user asked for the general
   photo flow to show the real individual posts instead ("не одна
   огромная колбаса"), so it's now a flex row of 5 real `<img>`s
   (`post-1.png`…`post-5.png`, each already its own Instagram-post
   mockup, avatar/header/icons included) — same aspect (~0.64) as the
   old card math above, so the height formula still holds unchanged.
   v9 bugfix (2026-07-22, same day) — `width: max-content` is
   required on `.case-scroll-strip` now that it's a flex `<div>`
   instead of a single `<img>`. Without it, this absolutely-positioned
   flex container (`left: 50%`, no `right`) computed `width: auto` as
   50% of `.case-scroll-carousel`'s own width in testing — half the
   frame, regardless of how wide the 5 cards actually were — leaving
   the other half of the frame visibly empty even at rest, before any
   parallax offset was applied at all ("половина пустая"). An `<img>`
   never had this problem because its own intrinsic width drove
   shrink-to-fit sizing directly; a flex container's content-based
   width apparently isn't inferred the same way in this
   position:absolute + left-only setup. Verified via
   `getBoundingClientRect()`: without the fix, `scrollWidth` (1595px,
   the real content width) and `offsetWidth` (700px) diverged sharply;
   with `width: max-content` they match. */
.case-scroll-carousel {
  position: relative;
  height: clamp(240px, 34.7vw, 460px);
  overflow: hidden;
}
.case-scroll-strip {
  position: absolute;
  top: 0;
  left: 50%;
  height: 100%;
  width: max-content;
  display: flex;
  gap: 1rem;
  transform: translateX(-50%);
  will-change: transform;
}
.case-scroll-strip img {
  height: 100%;
  width: auto;
  display: block;
}

/* full-bleed single image — flat, no border/rule, no scrim. Aspect
   ratio is set per-instance via an inline `style="aspect-ratio: …"`
   since each one is a deliberate crop (portrait lookbook shot vs. a
   wide signage banner), not a fixed system value. */
.case-figure { overflow: hidden; }
.case-figure img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* the mark, presented as a specimen rather than cropped into a
   figure — contained, centred, no background box (the source photo
   is already white/transparent, a paper-2 box under it just muddies
   it), so the logo reads at a glance instead of competing with a
   photographic crop */
.case-figure-mark {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: clamp(2rem, 9vw, 5rem) var(--gutter);
}
.case-figure-mark img { width: auto; height: clamp(160px, 22vw, 320px); display: block; }

/* two-up pairs — two related shots side by side as one narrative
   beat. Flat: plain gap, no ink gridline, no caption. */
.case-pair {
  list-style: none;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 2px;
  margin: 0;
  padding: 0;
}

.plate { aspect-ratio: 4 / 3; overflow: hidden; }
.plate img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* a poster/video pairing that keeps each item's OWN natural aspect
   ratio instead of force-cropping into .plate's fixed 4:3 — used
   where the source (e.g. a full A4 poster) needs to be seen whole,
   not cropped into a uniform grid cell. Each child sizes itself via
   its own aspect-ratio (set inline per-instance, same pattern as
   .case-figure). */
.case-pair-media {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 2px;
  align-items: start;
}
.case-pair-media img { width: 100%; display: block; }
.case-pair-media .video-frame {
  position: relative;
  overflow: hidden;
}
.case-pair-media .video-frame iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
  display: block;
}

@media (max-width: 640px) {
  .case-pair-media { grid-template-columns: 1fr; }
}

/* stacked half-width beats, alternating left/right — reuses the same
   `.case-half-item` primitive as `.case-split-half` above (defined
   there, not repeated here), just arranged in a column instead of a
   row: one full-bleed block per row instead of two side by side. */
.case-stack-half {
  display: flex;
  flex-direction: column;
}

/* "big" featured spreads (as opposed to the small wall-grid thumbnails
   further down the page) — direct follow-up request: "сделай на 20%
   меньше большие развороты и помести их на темный фон для контраста".
   Each spread is a white book page, so sitting it directly on the
   site's own off-white paper background gave it almost no edge
   definition; shrinking it and mounting it on --ink reads as a
   deliberate "plate on a dark mount" and echoes the wall section's own
   dark treatment instead of introducing a third, unrelated look. */
.case-spread-feature {
  background: var(--ink);
  padding: clamp(2rem, 5vw, 4rem) var(--gutter);
  display: flex;
  justify-content: center;
}
.case-spread-feature img {
  width: 80%;
  display: block;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
}
.case-spread-feature-pair { gap: clamp(1rem, 3vw, 2rem); }
.case-spread-feature-pair img { width: 38%; }

@media (max-width: 640px) {
  .case-spread-feature-pair { flex-direction: column; }
  .case-spread-feature-pair img { width: 80%; }
}

/* Schizorevolution's closing "wall of spreads" — the whole book laid
   out on the site's own dark ink, direct request with a reference
   screenshot to match exactly ("страницы поместим на наш фирменный
   черный фон... как на прикрепленном скриншоте"). This is the one
   place on the site that puts a shadow under content photography —
   everywhere else (Prostor's plates, case-pair, case-figure) is
   deliberately flat/borderless by an earlier, explicit decision — but
   the reference here shows each page as a separate floating sheet on
   black, and that was the specific brief for this one section, not a
   drift back toward the shadowed look that was rejected elsewhere.
   Thumbnails are a dedicated small export (wall-*.jpg, ~30-40KB each)
   — at 25 images, the full 1500px spread-*.jpg files would be ~8MB
   for this block alone; the small ones are just for the grid, while
   each tile's data-lightbox-gallery points at the full-res files for
   the actual full-size view. align-items: start keeps each image at
   its own natural height (the cover is portrait, spreads are
   landscape) rather than stretching every tile to match its row. */
.case-spread-wall {
  background: var(--ink);
  padding: clamp(1.6rem, 4vw, 3.2rem) var(--gutter);
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
  align-items: start;
  gap: clamp(0.8rem, 2vw, 1.6rem) clamp(0.6rem, 1.6vw, 1.2rem);
}
.case-spread-wall img {
  width: 100%;
  display: block;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
}
/* the cover is a single page (portrait), every other tile is a double-
   page spread (landscape, roughly twice as wide relative to its
   height) — at the same column width that made the cover render
   about 2x as tall as a spread tile ("открывающая страница стала в
   два раза больше"). Halving its width brings its rendered height
   back in line with the spread tiles beside it, reflecting the real
   page-vs-spread size relationship instead of fighting it with a
   forced/cropped aspect-ratio. */
.case-spread-wall img.is-single-page {
  width: 50%;
  margin: 0 auto;
}

/* Naja Slab Serif's actual webfont, self-hosted — the case study has
   almost no photography (just a cover shot and a specimen video), so
   rather than fake the type in a screenshot, the real .woff is loaded
   and set live on the page itself. Only a Regular weight exists. */
@font-face {
  font-family: "Naja Slab Serif";
  src: url("images/naja-slab-serif/NajaSlabSerif-Regular.woff") format("woff");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

.naja-specimen {
  background: var(--ink);
  color: var(--paper);
  padding: clamp(2rem, 6vw, 4rem) var(--gutter);
}
.naja-specimen p {
  font-family: "Naja Slab Serif", var(--font-body);
  font-size: clamp(1.4rem, 5vw, 3.2rem);
  line-height: 1.25;
  word-break: break-word;
}
.naja-specimen p + p { margin-top: 0.5em; }

/* ---------- lightbox: click any content photo to see it full-size ----------
   Generic and site-wide by design — nav.js attaches this to every
   <img> that isn't inside an <a> (work-list thumbnails stay plain
   navigation links, the header logo is excluded too), so any image
   added to any page picks this up automatically, no per-image markup
   needed. */
/* two earlier attempts at a hover affordance here, both since replaced:
   a real CSS `cursor: url()` eye (laggy/glitchy regardless of size,
   abandoned same day), then a floating eye <img> drawn above the
   pointer by nav.js (worked, but simplified to plain text on request
   — "просто на ховер появится надпись open над курсором"). The native
   cursor stays a plain pointer (see `.work-entry-link` etc.) — no
   custom cursor CSS at all here. */
img.is-lightboxable { cursor: pointer; }

/* the floating "Open" label itself — a dark chip (not bare coloured
   text) so it stays legible over any photo regardless of that photo's
   own brightness, same reasoning as the hero marquee's scrim. Hidden
   once the lightbox opens (nav.js hides it on click). */
.lightbox-cursor-label {
  position: fixed;
  top: 0;
  left: 0;
  transform: translate(-50%, calc(-100% - 20px));
  pointer-events: none;
  z-index: 60;
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--paper);
  background: var(--ink);
  padding: 0.35rem 0.7rem;
  white-space: nowrap;
  opacity: 0;
  transition: opacity 0.15s ease;
}
.lightbox-cursor-label.is-visible { opacity: 1; }

.lightbox-overlay {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: clamp(1rem, 5vw, 3rem);
  background: rgba(33, 30, 29, 0.92);
  cursor: zoom-out;
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.15s ease, visibility 0s linear 0.15s;
}
.lightbox-overlay.is-open {
  opacity: 1;
  visibility: visible;
  transition: opacity 0.15s ease;
}
.lightbox-overlay img {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  object-fit: contain;
  cursor: default;
}
body.lightbox-open { overflow: hidden; }

/* prev/next — only shown when the page actually has more than one
   lightboxable photo to step through (`.has-multiple`, set once in
   nav.js at setup, since the candidate count never changes at runtime) */
.lightbox-nav {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  background: none;
  border: 0;
  padding: 0.75rem 1rem;
  font-family: var(--font-mono);
  font-size: 2rem;
  line-height: 1;
  color: var(--paper);
  opacity: 0.6;
  cursor: pointer;
  transition: opacity 0.15s ease;
}
.lightbox-nav:hover { opacity: 1; }
.lightbox-prev { left: 0; }
.lightbox-next { right: 0; }
.lightbox-overlay:not(.has-multiple) .lightbox-nav { display: none; }

/* "n / total" position indicator — same visibility rule as the arrows */
.lightbox-counter {
  position: absolute;
  bottom: clamp(1rem, 4vw, 2rem);
  left: 50%;
  transform: translateX(-50%);
  font-family: var(--font-mono);
  font-size: 0.8rem;
  letter-spacing: 0.05em;
  color: var(--paper);
  opacity: 0.75;
}
.lightbox-overlay:not(.has-multiple) .lightbox-counter { display: none; }

/* previous/all/next — .case-nav-item.is-disabled (plain, unlinked,
   dimmed span) is for when there's genuinely no neighbouring case
   study to point to yet (e.g. Prostor's "previous", Mamy i Papy's
   "next" — Prostor is first, Mamy i Papy is second, nothing before
   or after either of those ends). Swap in a real
   <a class="case-nav-item"> the moment a real neighbour exists —
   don't leave a fake href pointing nowhere. */
/* a 3-column grid, not flex + space-between — space-between only
   centres the middle item when the two flanking items happen to be
   equal width, which "← Previous work" / "Next work →" aren't (the
   real bug: "All works" was reported off-centre). Two equal 1fr
   side-columns guarantee equal left/right whitespace, so the middle
   `auto` column sits at the true visual centre regardless of how
   long the flanking labels are. */
.case-nav {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 1rem;
  padding: 3rem var(--gutter) 4rem;
  border-top: 1px solid var(--line-strong);
}
.case-nav-item:first-child { justify-self: start; }
.case-nav-item:nth-child(2) { justify-self: center; }
.case-nav-item:last-child { justify-self: end; }
.case-nav-item {
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 0.85rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--ink);
  text-decoration: none;
  border-bottom: 2px solid var(--ink);
  padding-bottom: 2px;
}
a.case-nav-item:hover { color: var(--accent); border-bottom-color: var(--accent); }
.case-nav-item.is-disabled {
  color: var(--ink-soft);
  opacity: 0.45;
  border-bottom-color: transparent;
}

@media (max-width: 640px) {
  .case-pair { grid-template-columns: 1fr; }
  /* "← Previous work" / "Next work →" wrapped onto two lines in their
     narrow 1fr grid column at the full 0.85rem size — same fix as the
     header nav and work-filter above */
  .case-nav-item { font-size: 0.72rem; }
}

/* ---------- about page ----------
   Reuses the same .case-head/.case-lede/.case-body rhythm as the
   case-study pages — one shared "long-form text page" pattern across
   the site rather than a bespoke layout just for About. */
.about-contact { margin: 0 0 0.75rem; }
.about-contact:last-child { margin-bottom: 0; }
.about-contact a {
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 1.05rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--ink);
  text-decoration: none;
  border-bottom: 2px solid var(--ink);
  padding-bottom: 2px;
}
.about-contact a:hover { color: var(--accent); border-bottom-color: var(--accent); }

/* ---------- case study: buy / contact-to-license ---------- */
/* same bold-mono-underline treatment as .about-contact, kept separate
   since it means something different here (a purchase or licensing
   call to action, not a general contact block) — a page with a real
   store link (e.g. Gumroad) gets a direct "Buy" link; a page without
   one gets a prompt to contact the author instead, "любым способом"
   (by any means) — see case-ardica.html vs case-naja-slab-serif.html */
.case-purchase { padding: 0 var(--gutter) 3rem; }
.case-purchase-note {
  font-family: var(--font-mono);
  font-size: 0.85rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--ink-soft);
  margin: 0 0 0.75rem;
}
.case-purchase p { margin: 0 0 0.75rem; }
.case-purchase p:last-child { margin-bottom: 0; }
.case-purchase a {
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 1.05rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--ink);
  text-decoration: none;
  border-bottom: 2px solid var(--ink);
  padding-bottom: 2px;
}
.case-purchase a:hover { color: var(--accent); border-bottom-color: var(--accent); }

/* ---------- 404 ---------- */

.not-found-body { padding: 1rem var(--gutter) 4rem; }

.not-found-body a {
  display: inline-block;
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 0.9rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  text-decoration: none;
  color: var(--ink);
  border-bottom: 2px solid var(--ink);
}
.not-found-body a:hover { color: var(--accent); border-bottom-color: var(--accent); }

/* ---------- footer ---------- */

.site-footer {
  position: relative;
  z-index: 2;
  background: var(--paper);
  border-top: var(--rule) solid var(--ink);
  padding: 1.5rem var(--gutter) 2.5rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 1.5rem;
  justify-content: space-between;
  font-family: var(--font-mono);
  font-weight: 700;
  font-size: 0.78rem;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  color: var(--ink-soft);
}

.site-footer a { text-decoration: none; color: var(--ink-soft); }
.site-footer a:hover { color: var(--ink); text-decoration: underline; }

/* ---------- focus ---------- */

a:focus-visible, button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  .hero-marquee-track { animation: none; }
}
