/*! PhotoSwipe main CSS by Dmytro Semenov | photoswipe.com */

.pswp {
  --pswp-bg: #000;
  --pswp-placeholder-bg: #222;
  

  --pswp-root-z-index: 100000;
  
  --pswp-preloader-color: rgba(79, 79, 79, 0.4);
  --pswp-preloader-color-secondary: rgba(255, 255, 255, 0.9);
  
  /* defined via js:
  --pswp-transition-duration: 333ms; */
  
  --pswp-icon-color: #fff;
  --pswp-icon-color-secondary: #4f4f4f;
  --pswp-icon-stroke-color: #4f4f4f;
  --pswp-icon-stroke-width: 2px;

  --pswp-error-text-color: var(--pswp-icon-color);
}


/*
	Styles for basic PhotoSwipe (pswp) functionality (sliding area, open/close transitions)
*/

.pswp {
	position: fixed;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
	z-index: var(--pswp-root-z-index);
	display: none;
	touch-action: none;
	outline: 0;
	opacity: 0.003;
	contain: layout style size;
	-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}

/* Prevents focus outline on the root element,
  (it may be focused initially) */
.pswp:focus {
  outline: 0;
}

.pswp * {
  box-sizing: border-box;
}

.pswp img {
  max-width: none;
}

.pswp--open {
	display: block;
}

.pswp,
.pswp__bg {
	transform: translateZ(0);
	will-change: opacity;
}

.pswp__bg {
  opacity: 0.005;
	background: var(--pswp-bg);
}

.pswp,
.pswp__scroll-wrap {
	overflow: hidden;
}

.pswp__scroll-wrap,
.pswp__bg,
.pswp__container,
.pswp__item,
.pswp__content,
.pswp__img,
.pswp__zoom-wrap {
	position: absolute;
	top: 0;
	left: 0;
	width: 100%;
	height: 100%;
}

.pswp__img,
.pswp__zoom-wrap {
	width: auto;
	height: auto;
}

.pswp--click-to-zoom.pswp--zoom-allowed .pswp__img {
	cursor: -webkit-zoom-in;
	cursor: -moz-zoom-in;
	cursor: zoom-in;
}

.pswp--click-to-zoom.pswp--zoomed-in .pswp__img {
	cursor: move;
	cursor: -webkit-grab;
	cursor: -moz-grab;
	cursor: grab;
}

.pswp--click-to-zoom.pswp--zoomed-in .pswp__img:active {
  cursor: -webkit-grabbing;
  cursor: -moz-grabbing;
  cursor: grabbing;
}

/* :active to override grabbing cursor */
.pswp--no-mouse-drag.pswp--zoomed-in .pswp__img,
.pswp--no-mouse-drag.pswp--zoomed-in .pswp__img:active,
.pswp__img {
	cursor: -webkit-zoom-out;
	cursor: -moz-zoom-out;
	cursor: zoom-out;
}


/* Prevent selection and tap highlights */
.pswp__container,
.pswp__img,
.pswp__button,
.pswp__counter {
	-webkit-user-select: none;
	-moz-user-select: none;
	-ms-user-select: none;
	user-select: none;
}

.pswp__item {
	/* z-index for fade transition */
	z-index: 1;
	overflow: hidden;
}

.pswp__hidden {
	display: none !important;
}

/* Allow to click through pswp__content element, but not its children */
.pswp__content {
  pointer-events: none;
}
.pswp__content > * {
  pointer-events: auto;
}


/*

  PhotoSwipe UI

*/

/*
	Error message appears when image is not loaded
	(JS option errorMsg controls markup)
*/
.pswp__error-msg-container {
  display: grid;
}
.pswp__error-msg {
	margin: auto;
	font-size: 1em;
	line-height: 1;
	color: var(--pswp-error-text-color);
}

/*
class pswp__hide-on-close is applied to elements that
should hide (for example fade out) when PhotoSwipe is closed
and show (for example fade in) when PhotoSwipe is opened
 */
.pswp .pswp__hide-on-close {
	opacity: 0.005;
	will-change: opacity;
	transition: opacity var(--pswp-transition-duration) cubic-bezier(0.4, 0, 0.22, 1);
	z-index: 10; /* always overlap slide content */
	pointer-events: none; /* hidden elements should not be clickable */
}

/* class pswp--ui-visible is added when opening or closing transition starts */
.pswp--ui-visible .pswp__hide-on-close {
	opacity: 1;
	pointer-events: auto;
}

/* <button> styles, including css reset */
.pswp__button {
	position: relative;
	display: block;
	width: 50px;
	height: 60px;
	padding: 0;
	margin: 0;
	overflow: hidden;
	cursor: pointer;
	background: none;
	border: 0;
	box-shadow: none;
	opacity: 0.85;
	-webkit-appearance: none;
	-webkit-touch-callout: none;
}

.pswp__button:hover,
.pswp__button:active,
.pswp__button:focus {
  transition: none;
  padding: 0;
  background: none;
  border: 0;
  box-shadow: none;
  opacity: 1;
}

.pswp__button:disabled {
  opacity: 0.3;
  cursor: auto;
}

.pswp__icn {
  fill: var(--pswp-icon-color);
  color: var(--pswp-icon-color-secondary);
}

.pswp__icn {
  position: absolute;
  top: 14px;
  left: 9px;
  width: 32px;
  height: 32px;
  overflow: hidden;
  pointer-events: none;
}

.pswp__icn-shadow {
  stroke: var(--pswp-icon-stroke-color);
  stroke-width: var(--pswp-icon-stroke-width);
  fill: none;
}

.pswp__icn:focus {
	outline: 0;
}

/*
	div element that matches size of large image,
	large image loads on top of it,
	used when msrc is not provided
*/
div.pswp__img--placeholder,
.pswp__img--with-bg {
	background: var(--pswp-placeholder-bg);
}

.pswp__top-bar {
	position: absolute;
	left: 0;
	top: 0;
	width: 100%;
	height: 60px;
	display: flex;
  flex-direction: row;
  justify-content: flex-end;
	z-index: 10;

	/* allow events to pass through top bar itself */
	pointer-events: none !important;
}
.pswp__top-bar > * {
  pointer-events: auto;
  /* this makes transition significantly more smooth,
     even though inner elements are not animated */
  will-change: opacity;
}


/*

  Close button

*/
.pswp__button--close {
  margin-right: 6px;
}


/*

  Arrow buttons

*/
.pswp__button--arrow {
  position: absolute;
  top: 0;
  width: 75px;
  height: 100px;
  top: 50%;
  margin-top: -50px;
}

.pswp__button--arrow:disabled {
  display: none;
  cursor: default;
}

.pswp__button--arrow .pswp__icn {
  top: 50%;
  margin-top: -30px;
  width: 60px;
  height: 60px;
  background: none;
  border-radius: 0;
}

.pswp--one-slide .pswp__button--arrow {
  display: none;
}

/* hide arrows on touch screens */
.pswp--touch .pswp__button--arrow {
  visibility: hidden;
}

/* show arrows only after mouse was used */
.pswp--has_mouse .pswp__button--arrow {
  visibility: visible;
}

.pswp__button--arrow--prev {
  right: auto;
  left: 0px;
}

.pswp__button--arrow--next {
  right: 0px;
}
.pswp__button--arrow--next .pswp__icn {
  left: auto;
  right: 14px;
  /* flip horizontally */
  transform: scale(-1, 1);
}

/*

  Zoom button

*/
.pswp__button--zoom {
  display: none;
}

.pswp--zoom-allowed .pswp__button--zoom {
  display: block;
}

/* "+" => "-" */
.pswp--zoomed-in .pswp__zoom-icn-bar-v {
  display: none;
}


/*

  Loading indicator

*/
.pswp__preloader {
  position: relative;
  overflow: hidden;
  width: 50px;
  height: 60px;
  margin-right: auto;
}

.pswp__preloader .pswp__icn {
  opacity: 0;
  transition: opacity 0.2s linear;
  animation: pswp-clockwise 600ms linear infinite;
}

.pswp__preloader--active .pswp__icn {
  opacity: 0.85;
}

@keyframes pswp-clockwise {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}


/*

  "1 of 10" counter

*/
.pswp__counter {
  height: 30px;
  margin-top: 15px;
  margin-inline-start: 20px;
  font-size: 14px;
  line-height: 30px;
  color: var(--pswp-icon-color);
  text-shadow: 1px 1px 3px var(--pswp-icon-color-secondary);
  opacity: 0.85;
}

.pswp--one-slide .pswp__counter {
  display: none;
}

/* Design tokens.
 *
 * Components style with inline `style={...}` (see CLAUDE.md), and a custom
 * property resolves *inside* an inline style — so a token redefined in a
 * `:root` media query changes every element consuming it with no specificity
 * fight and no `!important`. That is what lets one `--gutter` replace four
 * hand-tuned section paddings and stay replaced. The `kh-*` class hook +
 * `!important` override pattern stays for everything a single scalar can't
 * express, e.g. `grid-template-columns`.
 *
 * Consuming a token is opt-in per element, so redefining one below cannot
 * reach a page that still uses a literal. /sheets, /gallery and /records share
 * --page-max but still write their 40px gutters literally; those move onto
 * --gutter when the three listing heads become one component.
 */
:root {
  /* --- brand ------------------------------------------------------------ */
  --accent: #5b8def;
  /* NB: #070a12 == rgb(7, 10, 18). The hero scrim in Hero.tsx hand-expands
     that triplet for its mid-stops, because a *transparent* variant of a hex
     token isn't expressible. Change --bg → change those stops too. */
  --bg: #070a12;
  --panel: #0c1322;

  /* --- text ------------------------------------------------------------- */
  --text: #eef0f4;
  --text-soft: #d6dae3;         /* body copy: collapses #d3d7df + #c5cad4 */
  --text-muted: #aeb6c4;
  --text-faint: #8189a0;
  --text-placeholder: #5b647c;

  /* --- hairlines: one value per role, not per author -------------------- */
  --border-faint: rgba(255, 255, 255, 0.08);
  --border-row: rgba(255, 255, 255, 0.12);
  --border-strong: rgba(255, 255, 255, 0.28);  /* ghost button at rest */
  --border-bright: rgba(255, 255, 255, 0.6);   /* …and on hover */

  /* --- fonts ------------------------------------------------------------
     The second entry is what the page renders in while the webfont is still
     loading. Georgia is a far closer metric match for Cormorant/Spectral than
     the default serif, so the reflow when the real face arrives is much
     smaller. This is the one deliberate visual change in the token pass. */
  --font-display: 'Cormorant Garamond', Georgia, serif;
  --font-ui: 'Manrope', system-ui, sans-serif;
  --font-body: 'Spectral', Georgia, serif;

  /* --- type ladder ------------------------------------------------------ */
  --fs-hero: clamp(38px, 6.4vw, 86px);
  --fs-1: clamp(34px, 4.6vw, 54px);   /* section titles */
  /* Panel titles sit a step below section titles. They used to be 50 vs 54,
     which is not a step — it is a wobble; and "О хоре" is close enough to
     "Голос Физтеха" on the page that equal sizes made them read as siblings
     rather than as heading and subheading. */
  --fs-panel: clamp(28px, 3.2vw, 40px);
  --fs-2: 34px;
  --fs-3: 26px;
  --fs-lead: 19px;
  --fs-body: 16px;
  --fs-meta: 14px;
  --fs-ui: 13px;
  --fs-eyebrow: 12px;

  /* --- spacing ---------------------------------------------------------- */
  --sp-1: 4px;
  --sp-2: 8px;
  --sp-3: 12px;
  --sp-4: 16px;
  --sp-5: 24px;
  --sp-6: 32px;
  --sp-7: 48px;
  --sp-8: 64px;
  --sp-9: 88px;
  --sp-10: 120px;

  /* --- layout ----------------------------------------------------------- */
  --gutter: 40px;
  --measure: 1180px;    /* home. /sheets/:id stays literal at 760 — it is a
                           reading column, not a listing. */
  /* The three listing pages — /sheets, /gallery, /records — share one column
     so switching between them does not shift the page under the reader.
     /sheets was 980 and read as a narrower site than its own gallery. */
  --page-max: 1240px;
  --section-pt: 96px;
  --section-pb: 112px;
  /* Two heights, and they are not the same number. `--header-bar-h` is the
     *painted* bar — what a visitor sees, and therefore the only thing a sticky
     element may park under. `--header-h` is the fixed box around it, which is
     taller on purpose: it is the hover target on the home page and the
     breathing room an anchor jump leaves above a section title.
     `.kh-gmonth` read `--header-h` and parked 27px low, so a band of the
     previous month slid through the gap between the two.

     Both are **responsive** — retuned in the mobile blocks below, because a
     44px tap target and (under 360px) a second row of labels make the bar
     taller than 51px. `.kh-gmonth` reads the number rather than measuring the
     bar, so the token has to *equal* the painted height, never merely bound
     it: a `min-height` here would make the bar's real height a consequence of
     its content while the sticky label still trusted a declared value, which
     is the 27px gap all over again. That in turn is why the mobile line count
     is forced (`.kh-nav-break`) instead of left to wherever the labels wrap. */
  --header-bar-h: 51px;
  --header-h: 78px;

  /* --- radii ------------------------------------------------------------ */
  --r-sm: 2px;
  --r-md: 4px;
  --r-lg: 10px;
  --r-pill: 999px;

  /* --- motion ----------------------------------------------------------- */
  --ease: cubic-bezier(0.2, 0.7, 0.2, 1);

  /* --- the coupled light sources ---------------------------------------
     A section's top edge and the header's bottom edge must peak at the same
     ~5% white at 50%, or the tonal seam under the header comes back. Same
     geometry, mirrored origin — change one, change both. */
  --section-glow: radial-gradient(120% 60% at 50% 0%, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0) 60%);
  --header-glow: radial-gradient(120% 60% at 50% 100%, rgba(255, 255, 255, 0.05), rgba(255, 255, 255, 0) 60%);
}

* {
  box-sizing: border-box;
}

html {
  scroll-behavior: smooth;
  scrollbar-gutter: stable;
}

/* --- Page transitions ---
   Cross-document view transitions. Every navigation here is a real SSR page
   load, and on a near-black site the white-ish flash between two documents is
   the single most jarring thing on the page; this cross-fades them instead.
   It is CSS only — no router, no client-side fetching, no interception — so a
   browser without support (Firefox today) just navigates as before, and the
   pages stay independently addressable and cacheable.
   `navigation: none` under reduced motion, at the end of this file. */
@view-transition {
  navigation: auto;
}

/* The pseudo-tree inherits custom properties from the root element, so --ease
   resolves here. Short: this sits in front of a real navigation, and anything
   longer reads as latency rather than as motion. */
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 0.26s;
  animation-timing-function: var(--ease);
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-body);
  min-height: 100vh;
}

input::placeholder {
  color: var(--text-placeholder);
}

@keyframes kh-fade {
  from { opacity: 0; }
  to { opacity: 1; }
}

@keyframes kh-rise {
  from { opacity: 0; transform: translateY(26px); }
  to { opacity: 1; transform: none; }
}

.kh-link {
  transition: color 0.2s;
}

.kh-link:hover {
  color: var(--text) !important;
}

/* The flexbox line-break idiom: zero-height, 100% basis. Inert everywhere but
   the <=359px block, where it splits the seven nav labels 4+3 so the bar's
   height is exact by construction rather than by luck. See --header-bar-h. */
.kh-nav-break { display: none; }

/* One control, two skins. This lived as three separate inline paddings
   (15/28, 15/26, 14/26) that were meant to look identical. */
.kh-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  padding: 15px 28px;
  border: 1px solid transparent;
  border-radius: var(--r-sm);
  font-family: var(--font-ui);
  font-size: var(--fs-ui);
  font-weight: 600;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  text-decoration: none;
  cursor: pointer;
  transition: filter 0.2s, border-color 0.2s, color 0.2s, background 0.2s;
}

.kh-btn--solid {
  background: var(--accent);
  color: var(--bg);
}

.kh-btn--solid:hover {
  filter: brightness(1.08);
}

.kh-btn--ghost {
  border-color: var(--border-strong);
  color: var(--text);
}

.kh-btn--ghost:hover {
  border-color: var(--border-bright);
  color: var(--text);
}

.kh-row {
  position: relative;
  transition: background 0.2s;
}

.kh-row:hover {
  background: rgba(255, 255, 255, 0.025);
}

.kh-row:hover .kh-tix {
  color: var(--text) !important;
  border-color: var(--border-bright) !important;
}

/* Only a linked row is an <a>; its title takes the accent on hover, the same
   signal the playlist and repertoire rows give. */
.kh-ev-title {
  transition: color 0.15s;
}
a.kh-row:hover .kh-ev-title {
  color: var(--accent);
}

.kh-row::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0;
  bottom: 0;
  width: 0;
  background: var(--accent);
  transition: width 0.35s var(--ease);
}

/* Gated: these three move layout. A touch device has no hover, but iOS
   synthesises a sticky one on tap that never clears until the next tap
   elsewhere — so an ungated layout hover leaves a row nudged 10px right, or a
   photograph zoomed, indefinitely. Gating by *capability* also reaches the
   769–1024px touch-tablet band, which no max-width rule does.
   Wrapped where the rule already lives, never hoisted into one block at the
   end of the file: a media query adds no specificity, so wrapping in place is
   a provable cascade no-op, whereas relocating a `:hover` rule past an
   equal-specificity sibling silently flips which one wins (see .kh-chip). */
@media (hover: hover) {
  .kh-row:hover::before {
    width: 3px;
  }
}

.kh-rowin {
  transition: transform 0.35s var(--ease);
}

@media (hover: hover) {
  .kh-row:hover .kh-rowin {
    transform: translateX(10px);
  }
}

.kh-gimg {
  transition: transform 0.6s var(--ease), filter 0.4s;
}

@media (hover: hover) {
  .kh-gcell:hover .kh-gimg {
    transform: scale(1.06);
    filter: none;
  }
}

/* One frame of the home page's teaser strip.
   `flex-grow` is the photograph's own ratio (`--ar`, printed per cell by
   PhotoStrip.tsx) rather than a flat 1: at one shared height, widths
   proportional to ratio make every frame exactly its photograph's shape, so
   the strip crops nothing and still fills the row to the pixel.
   Hover *multiplies* that share instead of replacing it — a flat grow value
   would make a portrait expand half again as far as a landscape, and it would
   have to fight the inline `--ar` for specificity if it were declared there. */
.kh-strip-item {
  flex: 1 1 0;
  flex-grow: var(--ar, 1.5);
  min-width: 0;
  /* display/text-decoration land here rather than on .kh-gcell: the strip's
     cells are anchors, the /gallery grid's are not. */
  display: block;
  text-decoration: none;
  transition: flex-grow 0.55s var(--ease);
}

/* Gated: layout, and the sticky-hover case is at its worst here — a tapped
   frame would stay expanded to 2.6x its share until something else was
   tapped. The <=768px override further down stays: it covers a *mouse* user
   at a narrow window, where the strip is a vertical stack and hover has
   nothing to grow into, which `(hover: hover)` does not exclude. */
@media (hover: hover) {
  .kh-strip-item:hover {
    flex-grow: calc(var(--ar, 1.5) * 2.6);
  }
}

/* Placeholder tiles for an unpublished gallery — same geometry as a real
   cell, so the strip's shape is the design and photos only change the fill.
   The hairline is what makes them read as empty frames rather than as images
   that failed to load: on a near-black page an unbordered dark rectangle is
   indistinguishable from a hole. */
.kh-strip-blank {
  border-radius: var(--r-sm);
  border: 1px solid var(--border-faint);
}

.kh-reveal {
  opacity: 0;
  transform: translateY(34px);
  transition: opacity 0.8s ease, transform 0.8s var(--ease);
}

.kh-reveal.kh-in {
  opacity: 1;
  transform: none;
}

.kh-lede {
  margin: 0;
  font-family: var(--font-display);
  font-size: 22px;
  line-height: 1.6;
  max-width: 640px;
  color: var(--text-soft);
}

/* The About panels bleed to the viewport edge, but their *text* lines up with
   the section head above them, so the break reads as intentional rather than
   as a container bug. At 1440px this resolves to 170px; at or below --measure
   it collapses to a plain gutter. max() also absorbs the few px by which 50vw
   over-counts the scrollbar — it can only ever add padding, never overflow. */
.kh-split .kh-split-panel {
  padding-left: max(var(--gutter), calc(50vw - var(--measure) / 2 + var(--gutter)));
}
.kh-split--reverse .kh-split-panel {
  padding-left: var(--gutter);
  padding-right: max(var(--gutter), calc(50vw - var(--measure) / 2 + var(--gutter)));
}

/* About's closing block. The programme's back matter: a ruled label/value list,
   not a card grid — the rows share `/sheets`'s shape rather than inventing a
   third one. Repeated rows, so the styling lives here rather than inline. */
.kh-facts {
  margin: var(--sp-6) 0 0;
}

.kh-fact {
  display: grid;
  grid-template-columns: 108px 1fr;
  gap: var(--sp-4);
  padding: 14px 0;
  border-top: 1px solid var(--border-faint);
}

.kh-fact dt {
  font-family: var(--font-ui);
  font-size: var(--fs-ui);
  letter-spacing: 1.4px;
  text-transform: uppercase;
  color: var(--text-faint);
  /* The uppercase UI label is 13px against 16px body — nudged down so the two
     sit on the same optical line rather than on the same box top. */
  padding-top: 3px;
}

.kh-fact dd {
  margin: 0;
  font-size: var(--fs-body);
  line-height: 1.6;
  color: var(--text-soft);
}

/* The section head's aside sits beside the title on desktop; on a phone there
   is no room for a second column, so it drops under the title and aligns with
   it rather than hugging the right edge. */
@media (max-width: 768px) {
  .kh-sec-head {
    grid-template-columns: 1fr !important;
    gap: var(--sp-3) !important;
  }
  .kh-sec-aside {
    text-align: left !important;
  }
}

/* The aside's link is the only coloured thing in a section head, and every
   section head has at most one — so it is a rule rather than an inline style
   repeated once per section. */
.kh-sec-aside a {
  color: var(--accent);
  text-decoration: none;
}

/* Anchor jumps land the section flush under the fixed header — any smaller
   value leaves a strip of the previous section visible above the target.
   --header-h is the single definition; Header.tsx reads the same token.
   `[data-section]` is emitted by Section.tsx, so a new section needs no edit
   here — only the hero and the footer, which are not Sections, are named. */
[data-section],
#home,
#contacts {
  scroll-margin-top: var(--header-h);
}

/* --- Library (/sheets) ---
   These rows are server-rendered ~490 times, so their styling lives here as
   classes rather than inline. Inline styles would repeat every declaration per
   row and blow the page up by roughly 6x. The rest of the site keeps the
   inline-style + !important-override pattern; this is the list-row exception. */
@keyframes kh-menu {
  from { opacity: 0; transform: translateY(-6px); }
  to { opacity: 1; transform: none; }
}

/* Shared 5-column track, used by the column header and every row. */
.kh-lib-cols {
  display: grid;
  grid-template-columns: 1fr 96px 120px 130px 190px;
  align-items: center;
  gap: 16px;
}

/* --- The listing table, shared by /sheets and /records ---
   Only the column *track* is per page (.kh-lib-cols vs .kh-rec-cols); the
   head row, the row itself and the title are one definition, because the two
   pages are meant to read as one system and a copy drifts the first time
   either is retuned. */
.kh-lib-head-row,
.kh-rec-head-row {
  padding: 0 14px 12px;
  border-bottom: 1px solid var(--border-row);
  font-family: var(--font-ui);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--text-faint);
  animation: kh-rise 0.7s both 0.3s;
}

.kh-lib-row,
.kh-rec-row {
  padding: 15px 14px;
  border-bottom: 1px solid var(--border-faint);
  border-radius: var(--r-md);
  transition: background 0.15s;
  /* The whole row opens the file; /sheets' actions column reverts to default. */
  cursor: pointer;
}
.kh-lib-actions { cursor: default; }
/* Same trick as `.kh-gblock`, one row at a time: /sheets is 515 of these and
   only a screenful is ever on show. There are no images here, so this buys
   layout and paint rather than requests. 89px is *measured* over the real 515
   rows, not a guess; 58px was, and left the scrollbar a third short.

   Re-measured after titles were allowed to wrap on a phone (mean row height,
   which is the statistic that decides total document height — the median went
   bimodal once a title can take two lines, ~77px for one line and ~105 for
   two):

       320px  mean 94.6   median 88     p90 132.5
       360px  mean 89.6   median 77.5   p90 115
       390px  mean 87.3   median 77.5   p90 105
       480px  mean 82.2   median 77.5   p90 105
       >768   mean 72.9   median 76.4   p90 76.4

   So 89 now sits inside the mobile band it matters most for, and only
   over-estimates on desktop — the safe direction, and the same value as
   before, so this is a re-justification rather than a retune. Note `auto
   <length>` lets the browser swap in a real measurement once a row has
   rendered, so the constant only governs how badly the scrollbar lies before
   you have scrolled. To re-measure, force `contentVisibility: visible` on
   every row first: an offscreen row otherwise reports back the very intrinsic
   size you are trying to replace.

   Applies to the `.kh-lib-row` class alone — `.kh-rec-row` is a short
   playlist, never long enough to be worth skipping. */
.kh-lib-row { content-visibility: auto; contain-intrinsic-block-size: auto 89px; }
.kh-lib-row:hover,
.kh-rec-row:hover { background: rgba(255, 255, 255, 0.04); }
/* Filtered out by the client search — display:none loses to nothing here
   because the row's layout comes from a class, not an inline style. */
.kh-lib-row[hidden],
.kh-rec-row[hidden] { display: none; }

.kh-lib-main { min-width: 0; }

.kh-lib-title,
.kh-rec-title {
  min-width: 0;
  font-family: var(--font-display);
  font-size: 22px;
  line-height: 1.2;
  color: var(--text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* The row's own anchor: /sheets wraps the title, and wants it to take the
   accent on hover. */
.kh-lib-title a { color: inherit; text-decoration: none; }
.kh-lib-row:hover .kh-lib-title a { color: var(--accent); }

.kh-lib-sub {
  font-family: var(--font-ui);
  font-size: 12.5px;
  color: var(--text-faint);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  margin-top: 2px;
}

.kh-lib-badge {
  display: inline-block;
  vertical-align: middle;
  margin-left: 10px;
  font-family: var(--font-ui);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.8px;
  text-transform: uppercase;
  color: var(--accent);
  border: 1px solid rgba(91, 141, 239, 0.45);
  border-radius: 20px;
  padding: 2px 9px;
}

.kh-lib-cell-meta {
  font-family: var(--font-ui);
  font-size: 12.5px;
  color: var(--text-faint);
}
.kh-lib-cell-meta--strong {
  font-weight: 600;
  letter-spacing: 0.5px;
  color: var(--text-muted);
}

.kh-lib-actions { position: relative; display: flex; justify-content: flex-end; }

.kh-dlgroup {
  display: flex;
  align-items: stretch;
  border-radius: 30px;
  border: 1px solid rgba(91, 141, 239, 0.45);
  overflow: hidden;
}

.kh-dl {
  display: flex;
  align-items: center;
  gap: 7px;
  text-decoration: none;
  font-family: var(--font-ui);
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.5px;
  padding: 8px 14px;
  color: #cfe0ff;
  background: rgba(91, 141, 239, 0.12);
}
.kh-dl:hover { background: rgba(91, 141, 239, 0.22); color: #eaf1ff; }

.kh-cv {
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  border: none;
  border-left: 1px solid rgba(91, 141, 239, 0.4);
  color: #9fb6e8;
  font-family: var(--font-ui);
  font-size: 11px;
  background: rgba(91, 141, 239, 0.12);
}
.kh-cv:hover { background: rgba(91, 141, 239, 0.22); color: #eaf1ff; }

.kh-lib-menu {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 30;
  min-width: 190px;
  padding: 6px;
  border-radius: 10px;
  border: 1px solid var(--border-row);
  background: #0e1626;
  box-shadow: 0 14px 40px rgba(0, 0, 0, 0.5);
  animation: kh-menu 0.14s ease both;
}
.kh-lib-menu[hidden] { display: none; }

.kh-lib-menu-title {
  font-family: var(--font-ui);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--text-faint);
  padding: 5px 12px 7px;
}

.kh-mi {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  text-decoration: none;
  font-family: var(--font-ui);
  font-size: 12.5px;
  font-weight: 500;
  padding: 9px 12px;
  border-radius: 6px;
  color: #dfe4ee;
  transition: background 0.12s;
}
.kh-mi:hover { background: rgba(255, 255, 255, 0.06); }
.kh-mi-ext { color: var(--text-faint); font-size: 10.5px; letter-spacing: 0.5px; text-transform: uppercase; }
.kh-mi-arrow { color: var(--accent); font-size: 12px; }

/* Shown by the client only when a search matches nothing. */
.kh-lib-empty {
  text-align: center;
  padding: 120px 20px;
  color: var(--text-faint);
  font-family: var(--font-ui);
}
.kh-lib-empty[hidden] { display: none; }
.kh-lib-empty-title {
  font-family: var(--font-display);
  font-size: 30px;
  color: var(--text-faint);
  margin-bottom: 10px;
}

/* --- Repertoire leaf (home) ---
   Five programme lines that renew themselves, one at a time. Repeated markup,
   so it is styled here rather than inline.

   Three ideas, and each replaced something that was not working:

   1. **One rule per line, and it is the leader.** The leaf used to carry a
      hairline above every line *and* a leader across it — ten rules for five
      pieces, on a page that already rules its section heads. The row hairlines
      are gone; the leader stays, because it is the one that means something
      (it carries the eye from a title to its composer, the way a printed
      programme does) and the spacing alone separates the lines.
   2. **The leaf sits unlit.** Titles rest at --text-muted, not --text: five
      32px serif titles at full brightness were the brightest block on a page
      whose whole aesthetic is restraint, and they flattened into one glare.
      Exactly one line is lit — the one that just arrived — so the block has a
      moving point of attention instead of five equal ones. With no JavaScript
      every line stays at the resting level, a legible 9.7:1.
   3. **The rules draw themselves**, left to right, staggered down the leaf,
      like a stave being ruled across a page. It fires when the section is
      revealed and again on each swap, so the arriving piece rules its own line.

   Read-only, so no cursor, no hover and no accent: the accent means "this is
   interactive" everywhere else on the site (docs/DESIGN.md) and a programme
   line is not.

   The whole line is the animated unit for the swap, and that transition lives
   on it rather than on its children, so `transform` moves title, leader and
   composer as one piece — which is also why the only thing the composer
   transitions for itself is its colour. */
.kh-pool-line {
  display: flex;
  align-items: baseline;
  gap: var(--sp-4);
  padding: 20px 0;
  transition: opacity 0.5s var(--ease), transform 0.5s var(--ease);
}

/* min-width guards the wrap: a flex item is `auto`-minimum by default, so a
   long Cyrillic title would push the leader and the composer out of the column
   instead of wrapping. */
.kh-pool-title {
  min-width: 0;
  font-family: var(--font-display);
  font-size: clamp(24px, 2.4vw, 32px);
  line-height: 1.16;
  color: var(--text-muted);
  transition: color 0.7s var(--ease);
}

/* The lit line: the piece that just arrived. Set by the client and moved to the
   next arrival, so there is never more than one. */
.kh-pool-line.kh-pool-lit .kh-pool-title { color: var(--text); }
.kh-pool-line.kh-pool-lit .kh-pool-by { color: var(--text-muted); }

/* Baseline alignment puts a 1px box on the text baseline itself, which reads as
   a strikethrough on the composer; the nudge drops it to where a leader belongs.
   It shrinks and never grows, so a long title takes the space it needs.
   The nudge lives inside the keyframes as well — an `animation` on `transform`
   replaces the property outright, so a static `translateY` here would be
   dropped for the length of the draw and the rule would jump onto the
   baseline. */
.kh-pool-lead {
  flex: 1 1 auto;
  min-width: var(--sp-6);
  height: 1px;
  transform: translateY(-6px);
  transform-origin: left center;
  background: var(--border-faint);
}

@keyframes kh-rule {
  from { transform: translateY(-6px) scaleX(0); }
  to { transform: translateY(-6px) scaleX(1); }
}

/* Ruled when the leaf itself reaches the viewport, one line after another down
   the page. The trigger is the widget's own class, set by its own module —
   *not* the page's `.kh-reveal`: hung off the section's reveal the rules drew
   below the fold and were over before anyone could see them (a section reveals
   when its own top crosses it, and the leaf sits a head, a rule and 48px
   lower), and giving the leaf a nested `.kh-reveal` to fix that made its
   titles fade and rise on their own — the widget reaching outside itself to
   change how the page animates.

   Without the class the leaders are simply drawn, so no JavaScript and no
   IntersectionObserver both mean "a ruled leaf", never a blank one. */
.kh-pool-ruled .kh-pool-lead {
  animation: kh-rule 0.8s var(--ease) both;
  /* The stagger reads the line's own index (`--rep-i`, set in Repertoire.tsx),
     so this rule does not have to know how many lines the page renders. As one
     `nth-child` delay per line it did, and raising REPERTOIRE_TEASER would have
     drawn the extra lines with no delay at all. */
  animation-delay: calc(var(--rep-i, 0) * 90ms);
}

.kh-pool-by {
  font-family: var(--font-ui);
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 2px;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--text-faint);
  /* Colour only. The composer's fade and lift come from the line moving as one
     piece — `.kh-pool-out` is set on the line, never on this element — so
     transitions declared here for opacity and transform had nothing to
     interpolate and never once fired. */
  transition: color 0.7s var(--ease);
}

/* The out state. The line comes back down from here once its text has been
   replaced, so one rule buys both halves of the swap. FADE_MS in
   client/repertoire-pool.ts must match the line's transition duration. */
.kh-pool-out {
  opacity: 0;
  transform: translateY(-10px);
}

/* --- Gallery grid (also server-rendered per photo) --- */
.kh-gcell {
  position: relative;
  cursor: pointer;
  overflow: hidden;
  border-radius: var(--r-sm);
  background: var(--panel);
}
/* Three columns and a *row unit* half a landscape cell tall, so a cell's height
   is a span: 2 units → 3:2, 3 → 1:1, 4 → 2:3. `spanOf()` in
   lib/gallery-payload.ts decides which photograph gets which.

   There is deliberately no `grid-auto-flow` here. Every cell carries its own
   `grid-area`, packed by `packCells()` onto the shortest column — auto-flow
   snaps to row lines and leaves holes a short month has no later cell to fill,
   which shipped visibly empty quarters in two months of the fixture set. */
.kh-ggrid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  grid-auto-rows: var(--gunit);
  gap: 14px;
}
/* Months are separated from each other, not from the page head — a leading
   margin on the first block opened a dead band under the filter chips. */
.kh-gblock + .kh-gblock { margin-top: 40px; }

/* A month the visitor has not scrolled to is not laid out, not painted, and —
   the whole point — its previews are not fetched. A cell paints its preview as
   a CSS background, and backgrounds have no `loading="lazy"`, so before this
   the browser requested all 3224 of them on first paint: ~192 MB at a measured
   61 KB average. Skipping the offscreen blocks takes that to ~82 requests.

   The size estimate has to be good or the scrollbar lies until a block renders
   (a flat 1200px guess put the document at 137k px against a true 304k). It is
   derivable rather than measured: `.kh-ggrid` is 3 columns at every breakpoint,
   so `--gcell-h` is what a 3:2 cell measures, `--gunit` is half of that (minus
   the gap it spans across), and `--grows` — rendered per block by PhotoGrid.tsx
   — is the block's height in those units, the *sum of its cells' spans* over
   three columns rather than a cell count. The 50px is the sticky `.kh-gmonth`
   header above the grid — its 16/14 padding around a 13px UI caps line (46px
   measured), plus the 4px margin under it. Retune it whenever that head's type
   or padding moves; it was 61px while the head was a 27px serif.

   `--grows` is exact rather than estimated, because the layout is computed
   rather than flowed — `packCells()` returns the tallest column. `auto` stays
   anyway: it is what lets the browser replace the formula with a measurement
   once a block has rendered, and it costs nothing to keep. */
.kh-gblock {
  content-visibility: auto;
  /* min(100vw, --page-max) minus the two literal 40px gutters — spelled with
     the token so retuning --page-max cannot silently skew this estimate. */
  --gcell-h: calc((min(100vw, var(--page-max)) - 80px - 28px) / 3 * 2 / 3);
  /* Two units plus the gap they span across = one landscape cell. Solve for the
     unit, and a `kh-gs2` cell measures exactly what every cell used to. */
  --gunit: calc((var(--gcell-h) - 14px) / 2);
  contain-intrinsic-block-size: auto calc(var(--grows) * (var(--gunit) + 14px) + 50px);
}
/* The cell is sized by its grid row span; the image fills whatever that is.
   Absolute rather than `height: 100%` because the tag row is a sibling — a
   percentage height against a grid item works, but then the two of them are
   in-flow and the row would push the image up out of its own frame. */
.kh-gcell .kh-gimg { position: absolute; inset: 0; background-size: cover; background-position: center; filter: saturate(0.92); }
/* The strip is flex, not grid: its cells have no row span, so its image is sized
   by the cell instead of by a grid row — see `.kh-strip-item` above. */
.kh-strip-item .kh-gimg { position: static; width: 100%; height: 100%; }

/* Tag chips, top-right of a gallery cell.
   DESIGN.md rejects badge rows, and this stays the right side of that line by
   being the quietest thing that can still be read over a photograph: 10px UI
   type, no border, no accent, a blurred near---bg plate rather than a colour,
   and only two before the rest collapse into a count. They sit at 82% at rest
   and resolve on hover, because the photograph is the content and a
   full-strength label competes with it. `pointer-events: none` keeps the whole
   cell one click target for the lightbox. */
.kh-gtags {
  position: absolute;
  top: 8px;
  right: 8px;
  z-index: 2;
  display: flex;
  justify-content: flex-end;
  gap: 5px;
  max-width: calc(100% - 16px);
  pointer-events: none;
}
.kh-gtag {
  font-family: var(--font-ui);
  font-size: 10px;
  line-height: 1;
  letter-spacing: 0.03em;
  padding: 4px 7px;
  border-radius: var(--r-sm);
  color: var(--text-soft);
  /* --bg at 55%: the chip belongs to the page, not to the photo under it. */
  background: rgba(7, 10, 18, 0.55);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  /* A long value ("Актовый зал") truncates rather than pushing its neighbour
     out of the cell — the row must never wrap to a second line. */
  max-width: 13ch;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  opacity: 0.82;
  transition: opacity 0.3s var(--ease);
}
/* Gated, and deliberately with no touch counterpart: 82% at rest is the
   *designed* state (the chip must not compete with the photograph), so the
   hover resolve is an enhancement and a touch device loses nothing by not
   having it. Without the gate the tablet band kept a tapped cell's chips at
   full opacity for good. */
@media (hover: hover) {
  .kh-gcell:hover .kh-gtag { opacity: 1; }
}
.kh-gtag-more { color: var(--text-faint); }

/* The month divider is a small UI caps label, a rule, and its count — the
   serif-miniature-of-the-page-head version was tried and taken back out. The
   year takes the accent inside that label; it is the one decorative use of the
   accent on these pages and it is deliberate. */
.kh-gmonth {
  position: sticky;
  /* The *painted* header, not the box around it. Reading `--header-h` parked
     this 27px low and left a band of the previous month's photographs sliding
     through the gap. It was 63px before that, which was short the other way and
     slid the label under the header before it stuck. */
  top: var(--header-bar-h);
  z-index: 20;
  display: flex;
  align-items: center;
  gap: 16px;
  padding: 16px 0 14px;
  margin-bottom: 4px;
  background: var(--bg);
}
.kh-gmonth-label {
  font-family: var(--font-ui);
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--text);
  white-space: nowrap;
}
.kh-gmonth-year { color: var(--accent); }
.kh-gmonth-rule { flex: 1; height: 1px; background: var(--border-faint); }
/* Sentence case and untracked, exactly like the page head's «19 снимков · 2
   года» — this is the same annotation one level down. The year beside the month
   *is* tracked caps, because it is a label on the title rather than a sentence. */
.kh-gmonth-count {
  font-family: var(--font-ui);
  font-size: 12px;
  color: var(--text-faint);
  white-space: nowrap;
}

/* --- Lightbox theme (PhotoSwipe) ---
   PhotoSwipe's own stylesheet is concatenated ahead of this file by
   `src/build.ts`, and every value worth changing is a custom property, so the
   theme is a token remap rather than a set of overrides. `.kh-pswp` is the
   `mainClass` set in client/lightbox.ts.

   The icons go flat: secondary fill and stroke exist to keep a white glyph
   legible over an arbitrary photograph, and both are switched off because the
   backdrop here is 94% opaque near-black — the same bare muted glyphs the
   hand-rolled overlay had. z-index clears the records overlay (70), which
   clears the header (40) and a sticky month label (20). */
.kh-pswp {
  --pswp-root-z-index: 80;
  --pswp-bg: #04060c;
  --pswp-placeholder-bg: var(--panel);
  --pswp-icon-color: var(--text-muted);
  --pswp-icon-color-secondary: transparent;
  --pswp-icon-stroke-color: transparent;
  --pswp-icon-stroke-width: 0;
  --pswp-preloader-color: rgba(255, 255, 255, 0.08);
  --pswp-preloader-color-secondary: var(--accent);
  /* In front of a real navigation-free overlay, 333ms reads as lag; 0.26s is
     the same duration the cross-document view transition uses. */
  --pswp-transition-duration: 260ms;
}
.kh-pswp .pswp__button:hover { --pswp-icon-color: var(--text); }
/* The counter is metadata, in the same type as the month counts it sits under. */
.kh-pswp .pswp__counter {
  font-family: var(--font-ui);
  font-size: 13px;
  letter-spacing: 2px;
  color: var(--text-faint);
  opacity: 1;
  margin-inline-start: 4px;
}
/* Our one added control: a text glyph, not an SVG, so it needs the type set. */
.kh-pswp .pswp__button--download {
  font-family: var(--font-ui);
  font-size: 22px;
  line-height: 50px;
  color: var(--text-muted);
  text-align: center;
  text-decoration: none;
}
.kh-pswp .pswp__button--download:hover { color: var(--text); }

/* --- Filter pills (shared by /records and /gallery) ---
   One definition, two pages: the chips are the same control, and a second copy
   under a second name is how two listing pages stop looking like one system. */
.kh-filters { display: flex; flex-wrap: wrap; gap: 10px 26px; margin-top: 30px; }
.kh-chips { display: flex; flex-wrap: wrap; gap: 8px; }
.kh-chip {
  font-family: var(--font-ui);
  font-size: 12px;
  letter-spacing: 0.5px;
  padding: 7px 15px;
  border-radius: var(--r-pill);
  border: 1px solid rgba(255, 255, 255, 0.18);
  background: transparent;
  color: var(--text-muted);
  cursor: pointer;
  transition: color 0.2s, border-color 0.2s, background 0.2s;
}
/* NOTE these two are equal specificity and resolved by *source order* — the
   pressed rule wins under the cursor because it comes second. Anything that
   moves a `:hover` rule down the file (a "collect all the hover rules" refactor,
   say) flips that and a pressed chip loses its accent fill on hover. Gate hover
   in place, never by relocation. */
.kh-chip:hover { border-color: var(--accent); color: var(--text); }
.kh-chip[aria-pressed='true'] { background: var(--accent); border-color: var(--accent); color: var(--bg); }

/* The search pill's ✕. `display` is class-sourced, which beats the UA sheet's
   `[hidden] { display: none }` no matter the specificity — so the hidden state
   has to be spelled out, exactly as .kh-lib-menu[hidden] and .kh-recbox[hidden]
   already do. */
.kh-search-clear { display: flex; align-items: center; justify-content: center; }
.kh-search-clear[hidden] { display: none; }

/* The playlist. Same table shape as the library: a shared column track used by
   the head row and every row, so the two listing pages read as one system. The
   old 2-up card grid is gone — it looked like the photo gallery, and it shipped
   a `preload="metadata"` media element per recording. */
.kh-rec-cols {
  display: grid;
  grid-template-columns: 116px 1fr 110px;
  align-items: center;
  gap: 16px;
}

/* Everything else the row shares with .kh-lib-row is declared once, up with the
   library. What is left is what makes this row an <a>. */
.kh-rec-row {
  position: relative;
  color: inherit;
  text-decoration: none;
}

/* The leading cell has three states stacked in it: the афиша-style date block
   at rest, a play caret on hover, and the equaliser while this row is playing.
   The date block is in flow and defines the cell's height; the caret and the
   equaliser overlay it absolutely, so the column never resizes as they swap. */
.kh-rec-idx {
  position: relative;
}
.kh-rec-when {
  display: block;
  transition: opacity 0.15s;
}
/* The афиша date treatment at playlist density: same pattern — a bold numeral
   over a small caps label — a step smaller, because these rows are many. */
.kh-rec-day {
  display: block;
  font-family: var(--font-ui);
  font-weight: 600;
  font-size: 22px;
  line-height: 1;
  letter-spacing: -0.5px;
  color: var(--text-muted);
}
.kh-rec-monyr {
  display: block;
  margin-top: 3px;
  font-family: var(--font-ui);
  font-size: 10px;
  letter-spacing: 1.2px;
  text-transform: uppercase;
  color: var(--text-faint);
  white-space: nowrap;
}
.kh-rec-cue,
.kh-rec-eq {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: flex-start;
  transition: opacity 0.15s;
}
.kh-rec-cue { opacity: 0; color: var(--text); font-size: 12px; padding-left: 4px; }
/* Scoped with :not() rather than out-ranked by a longer selector afterwards —
   the playing row keeps its equaliser under the cursor, and no rule here
   depends on source order.
   Gated: this swap *hides* the date and *reveals* the caret, and the caret is
   the row's only signal that it is a player rather than a link. With no hover
   it never appeared at all, so a phone got a playlist with no play affordance;
   the `(hover: none)` block near the end of the file puts the caret beside the
   date at rest instead. */
@media (hover: hover) {
  .kh-rec-row:hover:not(.kh-rec-playing) .kh-rec-when { opacity: 0; }
  .kh-rec-row:hover:not(.kh-rec-playing) .kh-rec-cue { opacity: 1; }
}

.kh-rec-eq { opacity: 0; align-items: flex-end; gap: 2px; padding: 8px 0 8px 4px; }
.kh-rec-eq i { width: 3px; height: 100%; border-radius: 2px; background: var(--accent); transform-origin: bottom; }
@keyframes kh-eq {
  0%, 100% { transform: scaleY(0.3); }
  50% { transform: scaleY(1); }
}
.kh-rec-playing .kh-rec-when { opacity: 0; }
.kh-rec-playing .kh-rec-eq { opacity: 1; }
/* One animation, four phases: the bars differ only in duration and delay, so
   changing the easing or the curve is one edit rather than four. */
.kh-rec-playing .kh-rec-eq i { animation: kh-eq 0.8s ease-in-out infinite; }
.kh-rec-playing .kh-rec-eq i:nth-child(1) { animation-duration: 0.7s; }
.kh-rec-playing .kh-rec-eq i:nth-child(2) { animation-duration: 0.9s; animation-delay: 0.15s; }
.kh-rec-playing .kh-rec-eq i:nth-child(3) { animation-duration: 0.6s; animation-delay: 0.3s; }
.kh-rec-playing .kh-rec-eq i:nth-child(4) { animation-delay: 0.1s; }

.kh-rec-title { display: block; transition: color 0.15s; }
/* Split from the hover half: an accent title *is* the playing state, so under
   a sticky iOS hover a merely-tapped row would sit there looking like it is
   playing. The playing rule is unconditional; the hover one is not. */
.kh-rec-playing .kh-rec-title { color: var(--accent); }
@media (hover: hover) {
  .kh-rec-row:hover .kh-rec-title { color: var(--accent); }
}

.kh-rec-fmt {
  display: flex;
  align-items: center;
  gap: 6px;
  font-family: var(--font-ui);
  font-size: 11.5px;
  font-weight: 600;
  letter-spacing: 0.5px;
  color: var(--text-faint);
}
.kh-rec-fmt-mark { color: var(--accent); font-size: 12px; }

/* Playback position, drawn on the row's own bottom edge rather than as a
   separate bar — the row is the control, so the progress belongs to it. One
   element: a hard-stop gradient does what a nested filler <i> was doing, and
   its 0% fallback is the not-yet-playing state. */
.kh-rec-prog {
  position: absolute;
  left: 0;
  right: 0;
  bottom: -1px;
  height: 2px;
  opacity: 0;
  background: linear-gradient(to right, var(--accent) var(--rec-prog, 0%), transparent 0);
}
.kh-rec-playing .kh-rec-prog { opacity: 1; }

/* --- Video overlay --- */
.kh-recbox {
  position: fixed;
  inset: 0;
  z-index: 70;
  display: flex;
  align-items: center;
  justify-content: center;
  background: rgba(4, 6, 12, 0.95);
  animation: kh-fade 0.25s ease;
}
.kh-recbox[hidden] { display: none; }
.kh-recbox-frame { width: min(88vw, 1160px); }
.kh-recbox-video { width: 100%; max-height: 74vh; display: block; background: #000; border-radius: var(--r-md); }
.kh-recbox-meta { display: flex; align-items: baseline; gap: 12px; margin-top: 16px; flex-wrap: wrap; }
.kh-recbox-title { font-family: var(--font-display); font-size: 24px; color: var(--text); }
.kh-recbox-date { font-family: var(--font-ui); font-size: 13px; color: var(--text-faint); }
.kh-recbox-close {
  position: absolute;
  top: 24px;
  right: 28px;
  cursor: pointer;
  border: none;
  background: transparent;
  color: var(--text-muted);
  font-family: var(--font-ui);
  font-size: 24px;
  line-height: 1;
}
.kh-recbox-close:hover { color: var(--text); }

/* --- Mobile (<=768px) --- */
@media (max-width: 768px) {
  /* Retuning the tokens re-flows every element that consumes one, including
     the inline-styled ones — no per-element `!important` needed. Only tokens
     that are purely scalar belong here; anything structural still needs a
     class hook below. */
  :root {
    --gutter: 22px;
    --section-pt: 64px;
    --section-pb: 72px;
    /* 4px padding + a 44px tap target + 4px. Today's 51px bar was already
       12+13+12, so honouring the touch minimum costs one pixel. Both must be
       redefined on `:root` and nowhere else: Header.tsx reads them from an
       inline `height: var(…)`, which resolves against the ancestor chain. */
    --header-bar-h: 52px;
    --header-h: 72px;
  }

  /* Library: 5-col table can't fit a phone — drop the middle meta columns,
     keep the title + download control. */
  .kh-lib-head { padding: 60px 22px 22px !important; }
  .kh-lib-body { padding: 6px 22px 100px !important; }
  .kh-lib-cols { grid-template-columns: 1fr auto !important; }
  .kh-lib-cell-meta { display: none !important; }

  /* The download control, icon-only and touch-sized. Two things at once:
     the word «Скачать» is redundant on a phone (tapping the row opens the PDF
     anyway), and the pill was 133px of a 318px row — the reason titles had
     nowhere to wrap. 44+2+44 = 92px hands 41px back to the title.
     The 2px gap replaces .kh-cv's border-left as the divider: flush against
     each other, a 34px caret beside a download link made a boundary tap a coin
     flip between "save the file" and "open the formats menu". Now the two hit
     boxes are physically separate, centres 46px apart, and the group's own
     background shows through as the rule. */
  .kh-dl-label { display: none; }
  .kh-dl { width: 44px; min-height: 44px; padding: 0; gap: 0; justify-content: center; }
  .kh-cv { width: 44px; min-height: 44px; border-left: none; }
  .kh-dlgroup { gap: 2px; background: rgba(91, 141, 239, 0.35); }

  /* Titles wrap. Desktop's title column is ~560px and one nowrap line is
     right; at 390px it is ~210px, where «Всенощное бдение, фр…» is not a
     title. The clamp is a safety net, not the normal case: at ~21 Cormorant
     chars a line, three lines hold ~63 against a p99 of 65 over the real 515
     rows, so it fires on about 1% of the library. /records gets two — a
     concert name is short and its cell carries no subtitle.
     `.kh-lib-sub` deliberately stays one truncated line: ~34 chars fit against
     a p90 of 28, so wrapping it would cost every row a line to rescue a tenth
     of them, and a catalogue is scanned by its title.
     No `!important` — every property here is class-sourced, and this block
     sits after both the shared base rule and `.kh-rec-title { display: block }`. */
  /* The prefixed `-webkit-box` triad, not the unprefixed `line-clamp` — that
     one is still not everywhere as of mid-2026, and its failure mode is an
     unclamped column rather than a graceful one.
     `line-height: 1.25` because 1.2 is tight for Cormorant once there is a
     second line; `overflow-wrap` so no single unbreakable word can
     reintroduce horizontal overflow. */
  .kh-lib-title,
  .kh-rec-title { white-space: normal; }
  /* On /sheets the clamp goes on the *anchor*, not on `.kh-lib-title` — the
     «Сборник» badge is an inline-block sibling of it, so clamping the parent
     truncates the badge away with the overflowing text, and the one marker
     saying "this is a multi-piece collection" vanishes on exactly the long
     titles that are most likely to be collections. Clamping the anchor leaves
     the badge outside the box; `-webkit-box` is block-level, so it lands on
     its own line under the title, which is where a phone has room for it. */
  .kh-lib-title a {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 3;
    overflow: hidden;
    line-height: 1.25;
    overflow-wrap: break-word;
  }
  .kh-lib-badge { margin-left: 0; margin-top: 5px; }
  /* /records has no badge and no anchor — the cell is the whole title. Two
     lines: a concert name is short and the cell carries no subtitle. */
  .kh-rec-title {
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    overflow: hidden;
    line-height: 1.25;
    overflow-wrap: break-word;
  }

  /* Section content wrappers shrink horizontal padding so 320–480px viewports
     don't squeeze the body copy against the edges. */
  .kh-gallery-head { padding: 60px 22px 24px !important; }
  .kh-gallery-body { padding: 14px 22px 80px !important; }
  /* The grid keeps its three columns here, so only the gutter changes: 22px a
     side plus the two 14px gaps. Feeds the same intrinsic-size formula. */
  .kh-gblock { --gcell-h: calc((100vw - 44px - 28px) / 3 * 2 / 3); }
  .kh-footer-inner { padding: 48px 22px 40px !important; gap: 24px !important; }
  /* Stacked, the two link rows kept right-aligning against a left-aligned
     contacts block above them — a ragged edge that only reads as intentional
     when the footer is still two columns. And they are navigation, so they get
     the same 44px hit box the header's links do. */
  .kh-footer-links > div { justify-content: flex-start !important; align-items: center; }
  /* Every link in the footer, the email included — it is the one contact
     detail on the page and was an 18px-tall target. */
  .kh-footer-inner a { display: flex; align-items: center; min-height: 44px; }
  /* Each section's «Все снимки ↗» is the teaser's only way through to its page.
     inline-flex, not flex: the aside is a text line, not a column.
     Deliberately NOT extended to prose links (About's «напишите нам») — a 44px
     box on an inline link inside a paragraph breaks the line box it sits in. */
  .kh-sec-aside a { display: inline-flex; align-items: center; min-height: 44px; }

  /* Hero: fixed bg jitters on iOS Safari; let the bg scroll with content.
     Trim the bottom-anchored padding so CTAs sit inside the viewport. */
  .kh-hero-bg { background-attachment: scroll !important; }
  /* Horizontal padding now comes from --gutter; only the bottom anchor needs
     trimming so the CTAs sit inside the viewport. */
  .kh-hero-inner { bottom: 36px !important; }
  .kh-hero-title { white-space: normal !important; }
  .kh-hero-cta { gap: 10px !important; }
  .kh-hero-cta a { padding: 13px 20px !important; font-size: 12px !important; letter-spacing: 1.2px !important; }

  /* Playlist: the format column goes (the play state's caret/note already says
     which it is). The leading date block stays — it is the row's when — but
     narrows with a smaller numeral so a Cyrillic title keeps its width. */
  .kh-rec-cols { grid-template-columns: 88px 1fr !important; gap: 10px !important; }
  .kh-rec-fmt,
  .kh-rec-th-fmt { display: none !important; }
  .kh-rec-day { font-size: 17px !important; }
  .kh-rec-monyr { font-size: 9px !important; letter-spacing: 0.8px !important; }
  .kh-recbox-close { top: 14px !important; right: 16px !important; }

  /* Header: 7 nav items at 28px gap overflow narrow screens. Tighten + shrink.
     Tracking goes to 0 as well — 0.5px across the 43 glyphs of the seven
     labels is ~21px, which is most of what buys a single line at 360px. */
  #kh-nav-bar { padding: 4px 8px !important; }
  .kh-nav-items { gap: 14px !important; font-size: 12px !important; letter-spacing: 0 !important; flex-wrap: wrap !important; justify-content: center !important; }
  /* Vertical only: a 13px-tall hit box is the defect, and the row has no
     horizontal budget to spare. Class-sourced, and the links' inline style
     sets only colour and text-decoration, so no `!important` is needed. */
  .kh-nav-items a { display: flex; align-items: center; min-height: 44px; }

  /* Event rows: three tracks become one. The date flips from stacked to
     inline — "14 ИЮНЯ" on one line, since a stacked numeral looks orphaned
     in a single-column layout — and the hover nudge goes, being meaningless
     on touch. */
  .kh-row { grid-template-columns: 1fr !important; gap: var(--sp-3) !important; padding: 22px var(--sp-3) !important; }
  .kh-row-date { display: flex !important; align-items: baseline !important; gap: var(--sp-2) !important; }
  .kh-row-tix { text-align: left !important; }
  .kh-row:hover .kh-rowin { transform: none !important; }

  /* About: 2-col splits collapse to single column, swap order so image follows
     text. The measure-aligned side padding collapses with them; panel titles
     need no override because --fs-1 is a clamp. */
  .kh-split { grid-template-columns: 1fr !important; }
  .kh-split--reverse .kh-split-image { order: 2; }
  .kh-split .kh-split-panel,
  .kh-split--reverse .kh-split-panel {
    padding-left: var(--gutter) !important;
    padding-right: var(--gutter) !important;
  }
  .kh-split-image { min-height: 320px !important; }

  /* A 108px label track leaves ~230px for an address, which wraps to four
     lines. The rows stack instead. */
  .kh-fact { grid-template-columns: 1fr; gap: var(--sp-2); }
  .kh-fact dt { padding-top: 0; }

  /* Gallery strip: horizontal flex with hover-grow becomes a vertical stack;
     the desktop height looks comical on phones. Stacked, six cells would be
     ~1100px of scrolling, so the strip is a teaser of three (two when it is
     showing placeholders — blank tiles earn less room than photographs). */
  /* The grid keeps three columns on a phone, so a cell is ~110px wide — one
     chip already covers a third of the frame and two would sit on top of each
     other. Tags come back at tablet width; nothing else depends on them. */
  .kh-gtags { display: none; }

  /* A dozen tags wrap to five rows on a 390px screen and push the photographs
     off the fold — the filter would be taller than the thing it filters. Each
     group becomes a swipeable strip instead: fixed height, and the chip cut off
     at the right edge is what says there are more. The page's own scrollWidth
     is untouched, so this cannot introduce horizontal overflow. */
  .kh-chips {
    flex-wrap: nowrap;
    overflow-x: auto;
    scrollbar-width: none;
  }
  .kh-chips::-webkit-scrollbar { display: none; }
  /* 28px is under the touch minimum, and a full-height pill is the idiom for a
     swipe strip — which this row already is here, so the desktop composition
     the restraint rule actually judges is untouched. */
  .kh-chip { flex: 0 0 auto; display: inline-flex; align-items: center; min-height: 44px; padding: 0 16px; }

  /* The ✕ takes its 44px out of the pill's own right padding rather than
     adding to it, so the control grows 42 → 48px instead of to 64. Left
     padding stays 16 so the ⌕ keeps its inset. A class writing a shorthand
     over an inline one is the legal direction — the prohibition is on the
     reverse, an inline shorthand clobbering a side a class owns. */
  .kh-search-clear { min-width: 44px; min-height: 44px; }
  .kh-search-pill { padding: 2px 6px 2px 16px !important; }

  /* Repertoire teaser: two 22px display titles will not share a 390px line, so
     the columns stack — and stacked, twelve of them are a screen and a half of
     titles before the visitor reaches the footer. Six is still a range; the
     rest are one tap away behind the section's own link.
     Dropping every second entry, not the tail: the twelve are an even stride
     across the whole library (repertoireSample), and keeping the first eight
     would hand a phone the front of the alphabet — exactly what the stride
     exists to avoid. Halving it keeps the spread. */
  /* No room for a composer on the title's baseline at 390px — it drops under
     the title, which is also where a poster would put it, and the leader has
     nothing left to lead across. */
  .kh-pool-line { flex-wrap: wrap !important; gap: var(--sp-2) !important; padding: 20px 0 !important; }
  .kh-pool-title { flex: 1 0 100% !important; }
  .kh-pool-lead { display: none !important; }

  .kh-gallery-strip { flex-direction: column !important; height: auto !important; gap: 6px !important; }
  .kh-gallery-strip > * { min-height: 180px !important; }
  .kh-gallery-strip > *:nth-child(n + 4) { display: none !important; }
  .kh-gallery-strip > .kh-strip-blank:nth-child(n + 3) { display: none !important; }
  /* Stacked, so hover has nothing to grow into. Back to the frame's own share
     rather than to a literal 1, which would *shrink* a landscape. */
  .kh-strip-item:hover { flex-grow: var(--ar, 1.5) !important; }
}

/* --- Phone (<=480px) extra trims --- */
@media (max-width: 480px) {
  /* The budget at 360px is measured against `system-ui`, not Manrope: the nav
     is laid out in the fallback until the webfont lands, and an extra line
     inside a fixed-height bar during the FOUT is the exact bug this file is
     fixing. 7px of gap keeps ~20px of slack there. */
  .kh-nav-items { gap: 7px !important; font-size: 11px !important; }
  .kh-hero-inner { bottom: 28px !important; }
  .kh-hero-cta { flex-direction: column !important; width: 100% !important; }
  .kh-hero-cta a { width: 100% !important; justify-content: center !important; }
}

/* --- Very narrow (<=359px): the nav is two rows, on purpose ---
   Seven Cyrillic labels do not share a 359px line at any font size this design
   would accept, so the choice is not whether it wraps but whether the wrap is
   predictable. `--header-bar-h` must equal the painted bar (`.kh-gmonth` parks
   on it), and a wrap point that depends on which face has loaded is a wrap
   point that puts the second row outside the bar during the FOUT. So the split
   is forced at 4+3 by `.kh-nav-break`, and the height is then arithmetic:
   4 + 44 + 44 + 4. The cost is a 96px bar on a 320px-wide phone; that band is
   iPhone SE 1 and old Android, and 360px and up stay on one line. */
@media (max-width: 359px) {
  :root {
    --header-bar-h: 96px;
    --header-h: 116px;
  }
  .kh-nav-items { row-gap: 0 !important; }
  .kh-nav-break { display: block; flex-basis: 100%; height: 0; }
}

/* --- Touch (no hover) ---
   The counterpart to the `@media (hover: hover)` gates scattered above: those
   *withdraw* behaviour a pointer-less device cannot reach, this *adds back*
   the one piece of information that was only ever expressed through hover.
   Keyed on capability, not width, so it also covers the 769-1024px touch
   tablet band that no max-width rule reaches.
   Placed after the width blocks and before the reduced-motion block, which
   must stay last in the file. */
@media (hover: none) {
  /* The play caret at rest. On a pointer device the leading cell swaps date
     for caret on hover; with no hover the row lost its only sign that it is a
     player rather than a link. It cannot simply be revealed in place — it is
     `position: absolute; inset: 0`, so it would paint over the day numeral,
     and the date is content, not decoration. So the cell becomes a row and
     the two sit side by side. `.kh-rec-eq` stays absolute and still covers
     the caret's slot while playing, which is the same swap the pointer
     version performs. */
  .kh-rec-idx { display: flex; align-items: center; gap: 6px; }
  .kh-rec-cue {
    position: static;
    inset: auto;
    opacity: 1;
    padding: 0;
    color: var(--text-muted);
  }
  .kh-rec-playing .kh-rec-cue { opacity: 0; }

  /* 88px was measured for «ДЕКАБРЯ 2024» at 9px caps and has nothing spare for
     a caret. Scoped to `hover: none` so a narrow *desktop* window does not pay
     16px it has no caret to put there. */
  @media (max-width: 768px) {
    .kh-rec-cols { grid-template-columns: 104px 1fr !important; }
  }

  /* The home page's bar is hidden until scroll-or-hover (`data-nav-mode=auto`,
     applied by nav.ts). With no hover that reads as "no navigation at all
     until you scroll 80px". Pinned open here rather than in JS because the
     server cannot know the pointer type: a JS-only fix would fade the bar in
     after the bundle ran, and would do nothing at all with the bundle
     disabled. `!important` is what beats Header.tsx's non-important inline
     first-paint style; nav.ts carries the same predicate so the two layers
     agree rather than one silently overriding the other. */
  #kh-nav-bar[data-nav-mode='auto'] { transform: none !important; opacity: 1 !important; }
  /* With no hover zone to serve, the 78px container is pure dead space over
     the top of the hero — it has no background and no content, and it was
     swallowing every tap that landed on it. The bar re-enables itself. */
  #kh-nav { pointer-events: none !important; }
  #kh-nav-bar { pointer-events: auto; }
}

/* --- Reduced motion ---
   Last in the file so it wins on equal specificity.

   The .kh-reveal rule is load-bearing and must stay explicit: those sections
   start at `opacity: 0` and only the scroll handler adds `.kh-in`. Zeroing
   transition-duration does not clear an opacity that was never transitioned,
   so without this line the whole page below the fold stays invisible — which
   is also what happens if the bundle fails to load. */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }

  /* The `*` rule below cannot reach ::view-transition-*: those pseudo-elements
     are not matched by a universal selector, so the cross-fade has to be turned
     off at its source. */
  @view-transition { navigation: none; }

  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    /* Delays must go too, not just durations. The hero staggers its CTAs on
       an 0.8s delay with `both` fill, so zeroing only the duration still
       leaves them invisible for 0.8s — the wait, not the movement. */
    animation-delay: 0s !important;
    transition-delay: 0s !important;
  }

  .kh-reveal { opacity: 1 !important; transform: none !important; }
  /* Parallax is the motion people get sick from; pin the hero to the page. */
  .kh-hero-bg { background-attachment: scroll !important; }
  .kh-strip-item:hover { flex-grow: var(--ar, 1.5) !important; }
}
