/*
 * CMS visual-effects kit — baseline CSS.
 * Initial hidden state for reveal effects + reduced-motion + freeze-mode overrides.
 */

/* Set by JS on elements with data-fx="reveal" at init time.
   Never apply from HTML — guarantees no-JS fallback renders visible. */
.fx-reveal-init { opacity: 0; }

/* Freeze mode: Playwright ?test=1 disables all animated transitions
   so visual snapshots are deterministic. */
body.fx-frozen *,
body.fx-frozen *::before,
body.fx-frozen *::after {
  animation-duration: 0s !important;
  animation-delay: 0s !important;
  transition-duration: 0s !important;
  transition-delay: 0s !important;
}

body.fx-frozen .fx-reveal-init { opacity: 1 !important; }

/* blur-reveal hides via inline JS only (no init class). If freeze/no-reveal
   is toggled after a prior bind left inline opacity:0/blur, force it visible. */
body.fx-frozen [data-fx="blur-reveal"],
.fx-no-reveal [data-fx="blur-reveal"] { opacity: 1 !important; filter: none !important; }

/* Respect system preference. */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
  .fx-reveal-init { opacity: 1 !important; }
}

/* ─── Tailwind arbitrary-value extras ─────────────────────────────
 * Extra utility classes used by tektonika fixtures that aren't in the
 * precompiled /site/styles.css bundle. Loaded after the bundle so plain
 * cascade order applies. Keep this list short — every entry here is a
 * candidate for the next time /site/styles.css gets regenerated.
 * ────────────────────────────────────────────────────────────────── */

/* Sizes — special-offers card grid + team scroller. */
.h-\[260px\] { height: 260px; }
.h-\[14px\]  { height: 14px; }
.w-\[170px\] { width: 170px; }
.w-\[180px\] { width: 180px; }
.hover\:w-\[220px\]:hover { width: 220px; }

/* Text scales — special-offers value strings. */
.text-\[11px\] { font-size: 11px; line-height: 1; }
.text-\[22px\] { font-size: 22px; line-height: 1; }
.text-\[26px\] { font-size: 26px; line-height: 1; }
.text-\[30px\] { font-size: 30px; line-height: 1; }
.text-\[38px\] { font-size: 38px; line-height: 1; }
.text-\[42px\] { font-size: 42px; line-height: 1; }
.text-\[46px\] { font-size: 46px; line-height: 1; }

/* Decorative "%" on the home «Способы покупки» Ипотека card. */
.text-\[180px\] { font-size: 180px; line-height: 1; }
@media (min-width: 768px) {
  .md\:text-\[240px\] { font-size: 240px; line-height: 1; }
}

/* Hover flex-grow — special-offers expand-on-hover at md+ breakpoint. */
@media (min-width: 768px) {
  .md\:hover\:\[flex-grow\:1\.8\]:hover { flex-grow: 1.8; }
  /* Clamp the special-offers row to the site grid (max-width 2000px,
     centered) at md+ so wide viewports (≥2560px) do not let the row bleed
     past the rest of the page container. The arbitrary `md:max-w-[2000px]`
     and `md:mx-auto` classes are not in the precompiled /site/styles.css
     bundle — covered here so live blocks render correctly without a reseed. */
  .cms-special-offers__row {
    max-width: 2000px;
    margin-left: auto;
    margin-right: auto;
  }
  /* md:pt-16 is missing from the prebuilt Tailwind bundle (md:pt-14 / 24 / 36
     are present, 16 is not). Hero sections need it to land on 144px total
     clearance under the shell's `<main pt-20>` (80 + 64 = 144). */
  .md\:pt-16 { padding-top: 4rem; }
  /* Parking list-view cards rely on `md:grid` + `md:gap-6` + an arbitrary
     6-col template. The prebuilt bundle ships md:grid-cols-2/3/4 + md:gap-4/5/8
     but not bare md:grid, md:gap-6, or that arbitrary token. */
  .md\:grid { display: grid; }
  .md\:gap-6 { gap: 1.5rem; }
  /* md:flex-wrap is absent from the bundle too. The dark CTA cards on /about
     and /purchase need it: their copy is capped at 600px and the button pair is
     ~537px, so an unwrapped md row overflows the viewport from 768px until
     ~1170px. */
  .md\:flex-wrap { flex-wrap: wrap; }
  /* Home + /projects filter bar. It's a column below md and a row from md up, so
     the bottom-alignment the CTA wants must be scoped to md: an unscoped
     `items-end` aligns on the horizontal axis while the bar is still a column and
     shoved the entire filter group against the right edge, leaving the left half
     of the section empty.
     Deliberately NOT shipped as a generic `.md\:items-end` utility, even though
     that's the class the markup would normally use: the inner label+pills groups
     already carry it (dead — it's absent from the prebuilt bundle), and making it
     live bottom-aligns groups of very unequal height, which drops /projects'
     «Готовность» dropdown 216px at 768px.
     Below md the element carries no align-items class at all, so it keeps the
     flexbox default (stretch) and the group sits flush left. */
  .cms-filterbar { align-items: flex-end; }
  .md\:grid-cols-\[minmax\(0\,1\.5fr\)_140px_160px_minmax\(0\,1\.4fr\)_auto_auto\] {
    grid-template-columns: minmax(0, 1.5fr) 140px 160px minmax(0, 1.4fr) auto auto;
  }
}

/* The bundle ships no xl: flex utilities at all. The home «Способы покупки» row
   needs them: side-by-side from 1024 squeezes the mortgage calculator card to
   ~520px, and its own three-column interior then collapses the amount pills to
   40px — the figures render outside them. Stacked until 1280, where the card is
   back over 700px. */
@media (min-width: 1280px) {
  .xl\:flex-row { flex-direction: row; }
  .xl\:gap-4   { gap: 1rem; }
  .xl\:gap-6   { gap: 1.5rem; }
}

/* ── /parking filter row ──────────────────────────────────────────────────
   The reference template (180px 210px 1fr auto auto) only balances from about
   1280px. At 1024 the two fixed columns and the two auto-width toggles eat the
   row and collapse the 1fr price cell to ~100px — the min/max numbers then
   render *underneath* the toggle buttons and the range slider shrinks to a
   stub. Keep the toggles on a second row until all five fit, and trim the fixed
   columns a little at the crossover so the price cell clears its ~295px of
   content. Lives here rather than as a Tailwind utility because the prebuilt
   bundle only ships the reference's `lg:` variant of that arbitrary value. */
@media (min-width: 1024px) {
  .cms-pfilter { grid-template-columns: 180px 210px minmax(0, 1fr); }
}
@media (min-width: 1280px) {
  .cms-pfilter { grid-template-columns: 170px 200px minmax(0, 1fr) auto auto; }
}

/* Hide the home about-section team scroller per design feedback (kept in
   the seed for parity with the React reference; toggled off here). */
.cms-about .cms-team { display: none; }

/* Yellow primary buttons inside dark `.bg-foreground` sections must keep
   their black `text-primary-foreground` colour. The legacy site rule
   `.bg-foreground a { color: inherit }` (in /site/styles.css) wins over
   the Tailwind utility on its own — narrow it back here for primary CTAs. */
.bg-foreground a.bg-primary,
.bg-foreground button.bg-primary {
  color: hsl(var(--primary-foreground));
}

/* Same conflict for footer links: `.bg-foreground a { color: inherit }`
   (0,1,1) outranks the `.text-background/NN` opacity utilities (0,1,0),
   so footer nav + social links rendered full-opacity white instead of the
   reference 60%/70%. Restore the muted colour; keep the hover rule winning. */
.bg-foreground a.text-background\/60 { color: hsl(var(--background) / .6); }
.bg-foreground a.text-background\/70 { color: hsl(var(--background) / .7); }
.bg-foreground a.hover\:text-background:hover { color: hsl(var(--background)); }


/* Hero+offers viewport sizing — mirrors tektonika/src/pages/Index.tsx
 * wrapper that pins hero+offers to 100dvh - 5rem (header). The CMS layout has
 * the slots side-by-side (no joint wrapper), so we apply the calc here on the
 * hero block alone.
 *
 * This used to be a hard @media (min-height:830px) / (max-height:829px) pair —
 * hero ≈ viewport-header-offers on tall screens, hero alone filling the area
 * below the header on short ones. The switch was NOT monotonic: measured live,
 * an 830px-tall viewport gave a 470px hero and an 829px-tall one gave 746px, so
 * shrinking the window by 1px made the hero 59% TALLER. That is also the worst
 * case for the image crop below, and it is where a resized desktop window lands.
 * clamp() keeps the same two endpoints but never lets the short-viewport value
 * exceed the tall-viewport one, so the height only ever falls as the window
 * shrinks. 400px floor unchanged.
 *
 * The width cap is the crop fix. object-fit:cover on .cms-hero__image has to
 * crop the photo into this box, and height-driven sizing makes the box PORTRAIT
 * on a narrow screen (measured 453x746 = aspect 0.61 at a 501x826 window) while
 * every hero photo is landscape — 1299x560, 1672x941, 1184x384 on site 3. A
 * 3.08:1 banner then keeps 20% of its width, magnified ~5x. Capping the height
 * at 95vw below the lg breakpoint holds the box near square (0.92 on a 390px
 * phone) instead of 0.6; which part of the photo survives is the per-slide focal
 * point in slider_hero.css. ≥1024px is untouched — desktop keeps today's
 * geometry — and in practice the cap only binds below ~500px, because at 768px
 * the viewport-height term is already the smaller of the two.
 *
 * The cap is only reachable because slider_hero.css reclaims the caption/
 * indicator padding AND lowers the seed markup's 400px floor on phones. The
 * two are one change: with the old padding the box could not shrink without
 * crowding the text, and with the 400px floor still in place the cap would be
 * inert.
 *
 * 95vw -> 56.25vw, 2026-09-05, owner: "it shows the centre of an image, we want
 * to see more". 95vw held the box near square (0.94 at 390px) and a 16:9 photo
 * — which is what 1920x1080 makes nearly every image on this site — still kept
 * only 53% of its width. 56.25vw is 9/16, the photos' own ratio.
 *
 * The cap does NOT bind on a phone and is not meant to: this is a min-height
 * over an overlaid caption that measures 221px at 390px wide (113px of text,
 * the rest the clearance the indicator strip needs), and the 270px floor in
 * slider_hero.css wins below ~480px. The phone lands at 1.30 and ~78%; a lower
 * cap there would only bury the photo behind its own caption. A min-height can
 * never clip that caption — it grows — so the failure mode is cosmetic.
 *
 * Where the cap DOES bind is the band this rule exists for: a portrait tablet.
 * At 768-1023 the viewport is tall and the width has barely moved, so the
 * height-driven term hands the hero a 688x553 / 740x590 box — measured 40%.
 * That was the worst case in the whole sweep and it is the case an @media
 * (max-width: 767px) rule would have missed entirely.
 *
 * >=1024px is untouched: the viewport is landscape there and the geometry is
 * already right. */
.cms-hero-frame {
  --cms-hero-h: clamp(400px, calc(100dvh - 5rem - 280px), calc(100dvh - 5rem));
  min-height: var(--cms-hero-h);
}
@media (max-width: 1023px) {
  .cms-hero-frame { min-height: min(var(--cms-hero-h), 56.25vw); }
}

/* Flat detail hero grid — 2-col layout at lg breakpoint (floor plan + specs). */
@media (min-width: 1024px) {
  .lg\:grid-cols-\[1fr_420px\] { grid-template-columns: 1fr 420px; }
  .lg\:grid-cols-\[1fr_460px\] { grid-template-columns: 1fr 460px; }
  .lg\:grid-cols-\[2fr_1fr\]   { grid-template-columns: 2fr 1fr; }
  .lg\:grid-cols-\[3fr_2fr\]   { grid-template-columns: 3fr 2fr; }
  .lg\:col-span-3 { grid-column: span 3 / span 3; }
  .lg\:col-span-2 { grid-column: span 2 / span 2; }
  .lg\:grid-cols-5 { grid-template-columns: repeat(5, minmax(0, 1fr)); }
  /* /purchase bank-table rows use `hidden lg:grid grid-cols-[1.5fr_1fr_1fr_0.8fr]`;
   * the pre-built Tailwind bundle ships .lg:flex / .lg:hidden but not .lg:grid. */
  .lg\:grid { display: grid; }
}

/* Aspect-ratio arbitrary tokens used on flat / project cards. */
.aspect-\[4\/5\]  { aspect-ratio: 4 / 5; }

/* Header backdrop — bg-background/70 + backdrop-blur-xl are referenced by
 * tek-site-header (block 4334) but the precompiled Tailwind bundle only
 * ships /50 /95 + backdrop-blur-sm. Without these utilities the fixed
 * header renders fully transparent over the project hero. */
.bg-background\/70 { background-color: hsl(var(--background) / 0.7); }
.bg-background\/90 { background-color: hsl(var(--background) / 0.9); }
.backdrop-blur-xl {
  -webkit-backdrop-filter: blur(24px);
  backdrop-filter: blur(24px);
}
.cms-site-header.is-scrolled {
  background-color: hsl(var(--background) / 0.95);
  border-bottom: 1px solid hsl(var(--border) / 0.4);
}
/* Hide-on-scroll-down (reference Header.tsx): the fixed bar slides out of view
 * when scrolling down and returns on scroll up. Explicit transition so the
 * slide animates regardless of whether the utility `transition-all` shipped in
 * the precompiled bundle. */
.cms-site-header {
  transition: transform .3s ease-out, background-color .3s ease,
              backdrop-filter .3s ease, border-color .3s ease;
  will-change: transform;
}
.cms-site-header.is-hidden {
  transform: translateY(-100%);
  pointer-events: none;
}
/* The burger menu drops out of a `position: fixed` header, so its height is
 * NOT bounded by anything: page scrolling moves the document behind it and
 * leaves the panel exactly where it is. Unbounded, the tektonika menu runs to
 * 571px — taller than a phone in landscape (360dvh) — and its last two items,
 * the phone number and «Заказать звонок», sit below the fold with no way to
 * reach them. They are painted, hit-testable and permanently off-screen, which
 * is why this reads as "the call button stopped working in mobile mode"
 * rather than as a layout bug.
 *
 * dvh, not vh: on iOS/Android the URL bar is inside vh but not on screen, so a
 * vh-sized panel is still short by the height of the browser chrome at exactly
 * the moment it matters. 80px is the bar the menu hangs from (`h-20`).
 * overscroll-behavior keeps a flick at the end of the list from scrolling the
 * page underneath. */
.cms-mobile-menu {
  max-height: calc(100vh - 80px);
  max-height: calc(100dvh - 80px);
  overflow-y: auto;
  overscroll-behavior: contain;
}
/* premium-top-nav rides up with the hero and parks under the site header. That
 * is `position: sticky` in the block's own CSS (premium_sub_nav.css) — including
 * the transition that used to live here as `.is-stuck`, which only existed
 * because the bar was then positioned from a scroll handler and could not be
 * allowed to ease while it was tracking the hero. */

/* Seven nav items: making them fit without moving the header's edges.
 *
 * The bar is `justify-between` — logo block, nav, phone cluster — and the
 * seventh item («Безопасность», added 2026-09-07 with the /safety page) took
 * the nav from 489px to 605px. Because the links are flex children that
 * shrink, the surplus was spent WRAPPING captions onto a second line inside an
 * 80px bar («Как купить» measured 40px tall at 1024, «О компании» 45px). The
 * document never overflowed, so no scrollbar and no audit saw it.
 *
 * Three widths pinched, each at a breakpoint edge where the bar's own metrics
 * jump: 1024 (`lg:px-16` starts, 64px a side), 1280 (`xl:px-[100px]`) and 1536
 * (`2xl:px-[140px]` AND the «Заказать звонок» button appears, which is
 * `hidden 2xl:inline-flex` and adds ~200px to the right cluster).
 *
 * The container's padding is NOT what gets adjusted, though it is the obvious
 * lever. Those three values are the same 64 / 100 / 140 the `.site-container`
 * uses, so the logo sits exactly above the page's first character at every
 * width; halving one band moves the logo 32px inboard of the h1 under it, on
 * every page of the site. Measured, then reverted.
 *
 * So the room comes from the nav itself:
 *   • 1024–1279 is the hard one — 560px of room for 509px of captions leaves
 *     51px for six gaps. The captions step down to 14px there, which returns
 *     ~34px of ink, and the gap goes to 8px. Nothing else on the bar moves.
 *   • 1280 and up keep the 15px captions and only lose the `xl:gap-8` step,
 *     which is what pinched 1280 and 1536. 16px is the same gap the bar
 *     already uses below 1280, so this reads as one spacing, not two.
 *
 * Written as media queries rather than `lg:text-sm` / `xl:gap-4` utilities:
 * the stylesheet is a FROZEN Tailwind build served from `site_shells.styles`
 * and neither class is in it, so a utility here would silently do nothing.
 * Same reasoning as about_page.css's principles-grid rule.
 *
 * Bounds end in .98px: a fractional viewport width (browser zoom, a hidpi
 * scale factor) can land between two integer bounds and match neither.
 *
 * nowrap is a backstop, not the fix. If an eighth item is ever added it must
 * announce itself as an overlap rather than hide as a silent second line. */
@media (min-width: 1024px) {
  .cms-site-header > div > nav > a,
  .cms-site-header > div > nav > .header-dropdown > a { white-space: nowrap; }
}
@media (min-width: 1024px) and (max-width: 1279.98px) {
  .cms-site-header > div > nav { gap: 0.5rem; }
  .cms-site-header > div > nav > a,
  .cms-site-header > div > nav > .header-dropdown > a { font-size: 14px; }
}
@media (min-width: 1280px) {
  .cms-site-header > div > nav { gap: 1rem; }
}

/* Header dropdown panel sizing — pre-built Tailwind bundle doesn't ship the
 * arbitrary `min-w-[…]` values these panels use. Semantic classes here keep
 * the seed HTML free of inline `style=` (enforced by tests). */
/* min() so a card can never be wider than the window itself — bindHeaderDropdowns
 * can slide an over-wide panel back into view, but not one that doesn't fit. */
.header-dropdown-card--projects   { min-width: min(780px, calc(100vw - 32px)); }
.header-dropdown-card--apartments { min-width: min(680px, calc(100vw - 32px)); }
.header-dropdown-list--projects   { min-width: 340px; }
.header-dropdown-list--apartments { min-width: 240px; }
.header-dropdown-promo            { min-width: 300px; }

/* Header dropdowns — hover-revealed mega-menu panels. Panel hidden by
 * default; .is-open on the wrapping `[data-fx="header-dropdown"]` toggles
 * the panel opacity + slide-up. Bridge zone (the .header-dropdown-bridge
 * div) keeps the panel alive while the cursor crosses the gap between
 * trigger and panel content. */
/* --dd-shift is the viewport-overhang correction published by bindHeaderDropdowns
 * before the panel opens (0 unless the panel would run past the right edge). */
.header-dropdown-panel {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transform: translate(var(--dd-shift, 0px), 8px);
  transition: opacity 250ms cubic-bezier(0.22, 1, 0.36, 1),
              transform 250ms cubic-bezier(0.22, 1, 0.36, 1),
              visibility 0s linear 250ms;
}
.header-dropdown.is-open .header-dropdown-panel {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  transform: translate(var(--dd-shift, 0px), 0);
  transition: opacity 250ms cubic-bezier(0.22, 1, 0.36, 1),
              transform 250ms cubic-bezier(0.22, 1, 0.36, 1),
              visibility 0s linear 0s;
}

/* Shared backdrop overlay — fades in under the header while any dropdown
 * is open. Sits below the header (z-50) but above the page content. */
.header-dropdown-backdrop {
  position: fixed;
  left: 0;
  right: 0;
  top: 5rem;   /* below the 80 px header */
  bottom: 0;
  background-color: rgba(0, 0, 0, 0.5);
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  z-index: 40;
  transition: opacity 250ms ease,
              visibility 0s linear 250ms;
}
.header-dropdown-backdrop.is-open {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  transition: opacity 250ms ease,
              visibility 0s linear 0s;
}

/* Fullscreen overlay must layer above the fixed site header (z-50). */
.z-\[10000\] { z-index: 10000; }
/* Required for the project-hero fullscreen overlay backdrop —
 * Tailwind's compiled bundle ships /50 but never .bg-black. Without
 * this rule the overlay is transparent and the info-bar / page content
 * leaks through the object-contain letterbox. */
.bg-black { background-color: #000; }

/* /project location section photo-card overlays + text */
.text-white\/70 { color: rgb(255 255 255 / 0.70); }
.text-white\/65 { color: rgb(255 255 255 / 0.65); }
.text-white\/35 { color: rgb(255 255 255 / 0.35); }
.via-black\/10 {
  --tw-gradient-via-position: ;
  --tw-gradient-to: rgb(0 0 0 / 0);
  --tw-gradient-stops: var(--tw-gradient-from), rgb(0 0 0 / 0.10) var(--tw-gradient-via-position), var(--tw-gradient-to);
}

/* /catalog filter & listing — additional arbitrary tokens. The bundle
 * compiled `lg:grid-cols-[1fr_420px|460px|2fr_1fr|3fr_2fr]` above; the
 * filter quick-row needs its own four-column hint at xl, and the listing
 * cards want a 4-up break at the 3xl tier (≥1600 px). */
@media (min-width: 1280px) {
  .xl\:grid-cols-\[190px_280px_minmax\(0\,1fr\)_minmax\(0\,1fr\)\] {
    grid-template-columns: 190px 280px minmax(0, 1fr) minmax(0, 1fr);
  }
}
@media (min-width: 1600px) {
  .\33xl\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}

/* Catalog rounded-pill panels — tek-specific large radii. */
.rounded-\[32px\]   { border-radius: 32px; }
.rounded-\[40px\]   { border-radius: 40px; }
.rounded-t-\[40px\] { border-top-left-radius: 40px; border-top-right-radius: 40px; }

/* Sort dropdown pill width. */
.min-w-\[220px\] { min-width: 220px; }

/* Discount toggle / chip lengths — tek uses h-[14px] elsewhere; the
 * listing card's floor-plan box is fixed at 260 px (already defined
 * above). The list-view mini plan uses w-[120px] / h-[100px]. */
.w-\[120px\] { width: 120px; }
.h-\[100px\] { height: 100px; }

/* tracking-[-0.03em] for the result-count headline. */
.tracking-\[-0\.03em\] { letter-spacing: -0.03em; }
.tracking-\[-1\.2px\]  { letter-spacing: -1.2px; }

/* Shared marquee (data-fx="marquee" + --fx-marquee-dur). */
@keyframes fx-marquee {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}
[data-fx~="marquee"] > * {
  animation: fx-marquee var(--fx-marquee-dur, 28s) linear infinite;
}

/* ─── Dual-range slider (cms-range-dual) ─────────────────────────────
 * Mirrors Radix <Slider> rendered by tektonika/src/components/ui/slider.tsx:
 * one shared horizontal track with two round thumbs, each thumb drag-able
 * across the full width. Implementation: two <input type="range"> stacked
 * absolutely over the same track (visible .bg-border bar + .bg-primary
 * fill positioned by JS). pointer-events:none on the input strips its
 * native track from intercepting clicks; pointer-events:auto on the
 * ::thumb lets the user drag only the thumb. Both inputs overlap so each
 * thumb can travel the full range — the JS handler (bindRangeSlider)
 * does directional clamp if lo > hi.
 */
.cms-range-dual {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  padding: 0;
  background: transparent;
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  pointer-events: none;
  outline: none;
}
.cms-range-dual::-webkit-slider-runnable-track {
  background: transparent;
  border: 0;
  height: 2px;
}
.cms-range-dual::-moz-range-track {
  background: transparent;
  border: 0;
  height: 2px;
}
/* Hide Firefox's default progress fill — we draw our own. */
.cms-range-dual::-moz-range-progress { background: transparent; }
.cms-range-dual::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 1.25rem;
  height: 1.25rem;
  border-radius: 9999px;
  background: hsl(var(--background));
  border: 2px solid hsl(var(--primary));
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
  cursor: pointer;
  pointer-events: auto;
  margin-top: -9px; /* webkit anchors the thumb top to the runnable-track top, so for a 2 px track + 20 px thumb we shift by -((20 - 2) / 2) = -9 px to center the thumb on the line */
  position: relative;
  z-index: 2;
  transition: transform 120ms ease, box-shadow 120ms ease;
}
.cms-range-dual::-moz-range-thumb {
  width: 1.25rem;
  height: 1.25rem;
  border-radius: 9999px;
  background: hsl(var(--background));
  border: 2px solid hsl(var(--primary));
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
  cursor: pointer;
  pointer-events: auto;
  transition: transform 120ms ease, box-shadow 120ms ease;
}
.cms-range-dual::-webkit-slider-thumb:hover,
.cms-range-dual::-webkit-slider-thumb:active {
  transform: scale(1.08);
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
.cms-range-dual::-moz-range-thumb:hover,
.cms-range-dual::-moz-range-thumb:active {
  transform: scale(1.08);
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
.cms-range-dual:focus-visible::-webkit-slider-thumb {
  outline: 2px solid hsl(var(--ring, var(--primary)));
  outline-offset: 2px;
}
.cms-range-dual:focus-visible::-moz-range-thumb {
  outline: 2px solid hsl(var(--ring, var(--primary)));
  outline-offset: 2px;
}

/* ─── Single-thumb slider (cms-range-solo) ───────────────────────────
 * Mirrors the visible shape of tektonika/src/components/ui/slider.tsx
 * (Radix-based) for /purchase mortgage sliders: 2 px track + 1.25 rem
 * round thumb with primary-coloured border + a yellow progress fill
 * from the left of the track up to the thumb.
 *
 * Progress fill is a linear-gradient on the WebKit track that switches
 * from primary → border at `--fx-range-pct`; JS sets the property on
 * every `input` event (bindPurchaseCalc.updateRangeFill). Firefox uses
 * its native ::-moz-range-progress pseudo-element instead.
 */
.cms-range-solo {
  -webkit-appearance: none;
  -moz-appearance: none;
  appearance: none;
  /* `display: block` is load-bearing: native <input type=range> defaults to
   * inline-block, which makes its parent a line-box ~font-size×line-height
   * (~24 px) tall instead of collapsing to the 2 px track. The reference
   * uses a Radix <span> so it never had this baseline gap; we force block
   * here to make the pill above sit flush with the track. */
  display: block;
  width: 100%;
  height: 2px;
  background: transparent;
  cursor: pointer;
  outline: none;
  margin: 0;
  padding: 0;
  overflow: visible;
}
.cms-range-solo::-webkit-slider-runnable-track {
  height: 2px;
  border-radius: 9999px;
  background: linear-gradient(to right,
    hsl(var(--primary)) 0%,
    hsl(var(--primary)) var(--fx-range-pct, 0%),
    hsl(var(--border)) var(--fx-range-pct, 0%),
    hsl(var(--border)) 100%);
}
.cms-range-solo::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 1.25rem;
  height: 1.25rem;
  border-radius: 9999px;
  background: hsl(var(--background));
  border: 2px solid hsl(var(--primary));
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
  cursor: pointer;
  margin-top: -9px; /* centre 20 px thumb on 2 px track */
  transition: transform 120ms ease, box-shadow 120ms ease;
}
.cms-range-solo::-moz-range-track {
  height: 2px;
  border-radius: 9999px;
  background: hsl(var(--border));
}
.cms-range-solo::-moz-range-progress {
  height: 2px;
  border-radius: 9999px;
  background: hsl(var(--primary));
}
.cms-range-solo::-moz-range-thumb {
  width: 1.25rem;
  height: 1.25rem;
  border-radius: 9999px;
  background: hsl(var(--background));
  border: 2px solid hsl(var(--primary));
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
  cursor: pointer;
  transition: transform 120ms ease, box-shadow 120ms ease;
}
.cms-range-solo::-webkit-slider-thumb:hover,
.cms-range-solo::-webkit-slider-thumb:active {
  transform: scale(1.08);
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
.cms-range-solo::-moz-range-thumb:hover,
.cms-range-solo::-moz-range-thumb:active {
  transform: scale(1.08);
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.12);
}
.cms-range-solo:focus-visible::-webkit-slider-thumb {
  outline: 2px solid hsl(var(--ring, var(--primary)));
  outline-offset: 2px;
}
.cms-range-solo:focus-visible::-moz-range-thumb {
  outline: 2px solid hsl(var(--ring, var(--primary)));
  outline-offset: 2px;
}

/* ─── Typeable calculator figures (cms-calc-input) ───────────────────
 * The numbers inside the «Калькулятор платежей» pills are <input>s that must
 * look exactly like the <span>s they replaced: same font, size, weight,
 * colour and position, no box of their own. Everything is inherited from
 * the pill row (`text-sm font-medium`, and `text-muted-foreground` for the
 * percent), so the input carries no typography of its own.
 *
 * --grow: the left-hand figure. It fills the row up to the unit, so its text
 * starts where the span's did and the unit stays pinned right by
 * justify-between. width:0 + flex-grow, not width:auto — an input's intrinsic
 * width comes from its `size` (≈20ch), which would push the unit around.
 * --pct: sits in front of «%», right-aligned in a fixed 4ch box («100»,
 * «23,5»), so its digits end exactly where the span's did.
 *
 * Height is the row's line-height (1.25rem for text-sm), matching the span's
 * line box so `items-center` puts both at the same y. */
.cms-calc-input {
  -webkit-appearance: none;
  appearance: none;
  display: block;
  box-sizing: border-box;
  margin: 0;
  padding: 0;
  border: 0;
  border-radius: 0;
  background: transparent;
  box-shadow: none;
  outline: none;
  font: inherit;
  color: inherit;
  letter-spacing: inherit;
  line-height: inherit;
  height: 1.25rem;
  text-overflow: clip;
  caret-color: currentColor;
}
.cms-calc-input--grow { flex: 1 1 0%; width: 0; min-width: 0; }
.cms-calc-input--pct  { display: inline-block; vertical-align: baseline; width: 4ch; text-align: right; }
/* Where the browser can size a field to its text, both behave exactly like
 * the spans did: the percent is as wide as its digits, and the left figure
 * can never be squeezed below its text (a span's min-content) — at 768 the
 * row has no spare width, and a fixed 4ch percent clipped the amount. Other
 * browsers get the same widths from bindMortgageCalc (fitCalcInput). */
@supports (field-sizing: content) {
  .cms-calc-input--grow { field-sizing: content; flex: 1 1 auto; width: auto; min-width: auto; }
  .cms-calc-input--pct  { field-sizing: content; width: auto; min-width: 1ch; }
}
/* Phones: iOS Safari zooms the page when a field under 16px takes focus. The
 * owner chose slightly larger figures on phones over a jumping page
 * (2026-09-28); tablets and desktops keep the pixel-exact 14px. */
@media (max-width: 767.98px) {
  .cms-calc-input { font-size: 16px; }
}
.cms-calc-input[readonly] { cursor: default; }
.cms-calc-input::-ms-clear { display: none; }

/* Focus indicator: the pill's own border darkens. A border-colour change, not
 * an outline, so nothing moves; the input itself shows only the caret. */
.cms-calc-pill { transition: border-color 150ms ease; }
.cms-calc-pill:focus-within { border-color: hsl(var(--foreground) / .55); }
@media (prefers-reduced-motion: reduce) {
  .cms-calc-pill { transition: none; }
}

/* Snap hint: bindMortgageCalc swaps the caption above a pill for the allowed
 * range («От 1 000 000 до 30 000 000 ₽») for ~3 s after it snaps a value.
 * One line, clipped, so a long hint can never wrap and push the pill down.
 * Not --destructive: the theme's light red is ~3.8:1 on white, under AA for
 * 12px text; this one is ~6:1. */
[data-fx-calc-caption][data-fx-hint-active] {
  color: var(--fx-calc-hint-color, hsl(0 74% 42%));
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ─── PillButton text-slide-up hover effect ─────────────────────────
 * Mirrors tektonika/src/components/PillButton.tsx: the button is a
 * `.group` with `overflow-hidden relative`, wraps its label in
 * `<span class="btn-text-slide">` containing TWO `.btn-text-slide-inner`
 * spans — the second `aria-hidden`. Both inner spans translate up on
 * `.group:hover`, sliding the visible label off and the duplicate in.
 * Heights are em-relative so any font-size works. */
.btn-text-slide {
  display: inline-flex;
  overflow: hidden;
  position: relative;
  height: 1.43em;
  line-height: 1.43;
  white-space: nowrap;
}
.btn-text-slide-inner {
  display: inline-flex;
  align-items: center;
  gap: 0.75rem;
  transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
  min-height: 100%;
}
.btn-text-slide-inner:last-child {
  position: absolute;
  top: 100%;
  left: 0;
}
.group:hover .btn-text-slide-inner {
  transform: translateY(-100%);
}

/* ─── mortgage-calc — flex-[3] missing from the prebuilt /site/styles.css.
 * The Ипотека card needs flex-grow=3 so it eats all the row space left by
 * the fixed 360px Рассрочка card; without this rule it falls back to
 * `flex: 0 1 auto` and stops growing at its content width, leaving a gap. */
.flex-\[3\] { flex: 3 1 0%; }

/* ─── Unified yellow-button interaction ─────────────────────────────
 * Every gold (--primary) CTA animates identically: a 0.2s ease fade plus a
 * whole-button 10% dim on hover. This collapses the drifted per-button hovers
 * (bg-primary/90 fade at ad-hoc durations, the raw `.cms-*` block-CSS hovers,
 * and the purchase-page text-slide) into ONE behaviour. Sizes still vary.
 *
 * The dim is `filter: brightness(.9)`, NOT `opacity:.9` (nor the seeds' old
 * `bg-primary/90` alpha). Any transparency composites the button with whatever
 * is BEHIND it — `0.9·gold + 0.1·backdrop` — so over a white section the hover
 * washed the gold LIGHTER (rgb 255,213,26) while over a dark `.bg-foreground`
 * hero/footer it went DARKER (rgb 231,188,1): the same button drifting opposite
 * directions per background (the v2 reference has this same opacity bug). filter
 * dims the button's own pixels before compositing, so the button stays opaque
 * and the result is backdrop-independent — a uniform ~10% darken everywhere.
 *
 * Scope is `.bg-primary` gold buttons + the two raw-CSS gold classes. The
 * Luxor/premium buttons use their own tan/maroon classes (`.cms-premium-*`,
 * not `.bg-primary`) and are deliberately left untouched.
 *
 * `!important` on the hover state is intentional: it must beat both the inline
 * `.hover\:bg-primary\/90:hover` utility (0,2,0) and the per-block
 * `.cms-about-bento .cms-cta-primary:hover` rule (0,3,0) regardless of the
 * order the block <style> vs this shared sheet load in.
 *
 * The `transition` list also needs `!important`: some gold buttons carry a
 * block transition that omits `filter` (e.g. `.cms-about-bento .cms-cta-btn`,
 * 0,2,0 — lists only `background-color,opacity`) which outranks a plain
 * element+class selector and loads after this sheet. Without !important the
 * brightness fade would SNAP instantly on those buttons instead of easing over
 * .2s (the «Связаться» about-bento CTA bug). `.cms-cta-primary` is added to the
 * selector so that class is covered; `.cms-fm__btn` is doubled because its base
 * transitions only `background-color,color`. */
a.bg-primary,
button.bg-primary,
.cms-cta-primary,
.cms-fm__btn.cms-fm__btn--yellow {
  transition: background-color .2s, border-color .2s, color .2s, filter .2s, opacity .2s !important;
}
a.bg-primary:hover:not(:disabled):not([aria-disabled="true"]),
button.bg-primary:hover:not(:disabled):not([aria-disabled="true"]),
.cms-fm__btn--yellow:hover:not(:disabled) {
  background-color: hsl(var(--primary)) !important;
  filter: brightness(.9) !important;
}

/* A gold CTA link inside a dark `.bg-foreground` section must NOT inherit the
 * legacy `.bg-foreground a:hover { opacity:.8 }` text-link dimming (styles.css:49,
 * meant for nav/text links). That rule fades the whole gold pill to 0.8 —
 * composited over the dark card it turns a muddy mustard on hover — shadowing the
 * button's own brightness fade. Two shapes both need covering:
 *   • the link IS the gold button  → `a.bg-primary` / `a.cms-cta-primary` /
 *     `a.cms-fm__btn--yellow` (e.g. /about «Смотреть проекты», /projects «Выбрать
 *     квартиру», project-detail «Записаться» — all `<a class="… bg-primary">`);
 *   • the link WRAPS a gold button → `:has(…)` (e.g. home «Перейти к проекту»).
 * Real text links keep their dimming. specificity (≥0,3,1) beats the legacy
 * (0,2,1); !important guards block-<style>-vs-shared-sheet load order. */
.bg-foreground a.bg-primary:hover,
.bg-foreground a.cms-cta-primary:hover,
.bg-foreground a.cms-fm__btn--yellow:hover,
.bg-foreground a:has(.bg-primary, .cms-cta-primary, .cms-fm__btn--yellow):hover { opacity: 1 !important; }

/* The footer CTA (`site_footer.html` «Заказать звонок») drifted from the pill
 * pattern — its markup omits the `uppercase tracking-[0.35px]` utilities every
 * other gold CTA carries, so it rendered mixed-case + un-spaced, visibly unlike
 * the rest. Match the family here (the {{label}} value stays lower-case; only
 * the rendering is uppercased, exactly like the `uppercase` utility does). */
footer a.bg-primary,
footer button.bg-primary { text-transform: uppercase; letter-spacing: .35px; }

/* Retire the purchase-page text-slide so its hero CTA matches the unified
 * fade above. The two-span `.btn-text-slide` markup stays inert in the DOM
 * (harmless — the duplicate label sits clipped below); only the motion is
 * disabled, and the button still picks up the brightness fade via `.bg-primary`. */
.btn-text-slide-inner { transition: none; }
.group:hover .btn-text-slide-inner { transform: none; }

/* ─── mortgage-calc slider lift — `-mt-4` (Tailwind negative margin-top of
 * 1rem) is also missing from the prebuilt bundle. The slider wrapper uses
 * it to pull the 2 px range track up so its centre sits at the value pill's
 * bottom edge (the pill renders with `border-b-0` to leave that edge open),
 * so the round thumb visually nuzzles into the open bottom — matching the
 * reference Radix Slider layout from MortgageCalculator.tsx. Without this
 * the input's intrinsic 24 px box leaves a ~15 px gap below the pill. */
.-mt-4 { margin-top: -1rem; }

/* ─── /media (news module) — extras missing from the prebuilt bundle ── */
.h-\[250px\] { height: 250px; }
.h-\[300px\] { height: 300px; }
.h-\[420px\] { height: 420px; }
.rounded-3xl { border-radius: 1.5rem; }   /* already in bundle — kept for safety */

/* ── the bill for patching a bare utility in here ──
 *
 * This sheet loads AFTER /site/styles.css, so an unconditional rule added above
 * out-orders the `md:`/`lg:` variant of the same utility inside the bundle's
 * media query — same specificity, later sheet wins. The breakpoint then does
 * nothing, silently, at every width.
 *
 * Measured, not theorised: `.h-\[420px\]` pinned the project-plans panel to
 * 420px on a 1440 desktop where its seed asks for `md:h-[560px]`, on four
 * mounts; `.text-\[22px\]` did the same to the purchase page's `md:text-[28px]`;
 * `.h-\[300px\]` did it to the parking plan box, which is why that box was
 * 380×300 instead of square. Nobody could see any of it — the element simply
 * kept its phone size forever.
 *
 * So every bare arbitrary-value utility patched in above owes its responsive
 * variants here. `.md\:h-\[260px\]` further down already does this for the
 * offers cards; these are the ones that were missing.
 *
 * scripts/kit-shadow-gate.mjs walks every seed and fails on a new one. */
@media (min-width: 768px) {
  .md\:h-\[560px\]   { height: 560px; }
  .md\:text-\[28px\] { font-size: 28px; }
}

@media (min-width: 1024px) {
  .lg\:w-\[400px\] { width: 400px; }
}

/* Page background for the /media listing + detail — reference uses #F7F7F7 on the
   outer wrapper; the block's <main> is the top-level wrapper here, so apply there. */
.cms-news-page { background-color: #F7F7F7; }

/* Dynamic heading-bar underline — bindHeadingBar (cms-effects.js) measures the
   first 2 chars of the H1 and writes width + transform-origin at runtime. 66px
   is the tektonika reference default before JS takes over. */
.cms-news-heading-bar { width: 66px; transform-origin: left; }

/* Editorial article body (/media/:slug) — mirrors the reference prose-* modifiers
   in PublicationContent.tsx. The @tailwindcss/typography plugin isn't in the
   precompiled bundle, so replicate the essentials here. */
.cms-news-prose h2,
.cms-news-prose h3,
.cms-news-prose h4 {
  font-family: var(--font-display, inherit);
  font-weight: 400;
  letter-spacing: -0.5px;
  margin-top: 2.5rem;
  margin-bottom: 1rem;
  line-height: 1.25;
}
.cms-news-prose h2 { font-size: 1.5rem; }
.cms-news-prose h3 { font-size: 1.25rem; }
.cms-news-prose h4 { font-size: 1.125rem; }
.cms-news-prose p {
  color: hsl(var(--muted-foreground));
  line-height: 1.7;
  margin-bottom: 1rem;
}
.cms-news-prose ul,
.cms-news-prose ol {
  margin-top: 1rem;
  margin-bottom: 1rem;
  padding-left: 1.25rem;
}
.cms-news-prose ul { list-style: disc; }
.cms-news-prose ol { list-style: decimal; }
.cms-news-prose li {
  color: hsl(var(--muted-foreground));
  line-height: 1.7;
  margin-bottom: 0.25rem;
}
.cms-news-prose strong {
  color: hsl(var(--foreground));
  font-weight: 500;
}
.cms-news-prose a {
  color: hsl(var(--primary));
  text-decoration: underline;
}
.cms-news-prose blockquote {
  border-left: 3px solid hsl(var(--border));
  padding-left: 1rem;
  color: hsl(var(--muted-foreground));
  font-style: italic;
  margin: 1.5rem 0;
}
/* Tables. Long-form legal copy (/privacy-policy) leans on these for the
   retention + operator-details grids; editorial articles get them for free.
   The wrapper scrolls instead of overflowing on narrow viewports. */
.cms-news-prose table {
  width: 100%;
  margin: 1.5rem 0;
  border-collapse: collapse;
  font-size: 0.9375rem;
  display: block;
  overflow-x: auto;
}
.cms-news-prose th,
.cms-news-prose td {
  border: 1px solid hsl(var(--border));
  padding: 0.75rem 1rem;
  text-align: left;
  vertical-align: top;
  color: hsl(var(--muted-foreground));
  line-height: 1.6;
}
.cms-news-prose th {
  color: hsl(var(--foreground));
  font-weight: 500;
  background: hsl(var(--muted));
}
.cms-news-prose caption {
  caption-side: top;
  text-align: left;
  padding-bottom: 0.5rem;
  color: hsl(var(--muted-foreground));
  font-size: 0.875rem;
}
.cms-news-prose hr {
  border: 0;
  border-top: 1px solid hsl(var(--border));
  margin: 2rem 0;
}

/* ─── Round 10: site-wide footer (tek-site-footer block) ───
 * Utilities below are either absent from the prebuilt Tailwind bundle
 * (`/site/styles.css`) or are arbitrary-value tokens the bundle can't
 * generate. Keep this list tight — only what the footer seed uses.
 * See feedback_tailwind_lg_grid_missing.md + feedback_shared_assets_main_module.md.
 */
.font-light { font-weight: 300; }
.px-7 { padding-left: 1.75rem; padding-right: 1.75rem; }
/* px-12 is missing from the prebuilt bundle (it ships px-8 / px-10 but not
 * px-12). The home + /projects "Все квартиры" CTA uses px-12 h-14; without it
 * the button collapsed to zero horizontal padding and the pill ends ate the
 * label into a yellow blob. */
.px-12 { padding-left: 3rem; padding-right: 3rem; }
.text-background\/50 { color: hsl(var(--background) / .5); }

/* Special-offers card labels use /65 (dark/light cards) and /75 (image cards);
 * card 5 (light, muted bg) uses text-brand-gray for its sub-label. None of
 * these utilities ship in the precompiled /site/styles.css, so a missing
 * patch makes every label fall back to inherited foreground (dark) — which
 * renders dark-on-dark on all chip backgrounds except the muted light card.
 */
.text-background\/65  { color: hsl(var(--background) / .65); }
.text-background\/75  { color: hsl(var(--background) / .75); }
.text-brand-gray      { color: hsl(var(--brand-gray)); }

/* Special-offers card gradient overlay — bg-gradient-to-t from-black/85
 * via-black/25 to-black/50. Bundle ships from-black/{60,70} + via-black/20
 * only; the missing /85 /25 /50 stops left the overlay producing
 * `background-image: none` (zero scrim, fully exposed image, light text
 * unreadable on bright backgrounds). Shape mirrors how Tailwind compiles
 * gradient utilities — see existing .from-black/60 in /site/styles.css.
 */
.from-black\/85 {
  --tw-gradient-from: rgb(0 0 0 / .85) var(--tw-gradient-from-position);
  --tw-gradient-to:   rgb(0 0 0 / 0)   var(--tw-gradient-to-position);
  --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
}
.via-black\/25 {
  --tw-gradient-to: rgb(0 0 0 / 0) var(--tw-gradient-to-position);
  --tw-gradient-stops: var(--tw-gradient-from), rgb(0 0 0 / .25) var(--tw-gradient-via-position), var(--tw-gradient-to);
}
.to-black\/50 {
  --tw-gradient-to: rgb(0 0 0 / .5) var(--tw-gradient-to-position);
}

@media (min-width: 640px) {
  .sm\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (min-width: 768px) {
  .md\:self-center { align-self: center; }
}
@media (min-width: 1024px) {
  .lg\:grid-cols-\[280px_1fr\] { grid-template-columns: 280px 1fr; }
}

/* Footer nav column lists — flat vertical stack, no bullets. Replaces
 * Tailwind's space-y-2.5 which isn't emitted by the purged bundle. */
.cms-footer-nav-list {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  flex-direction: column;
  gap: 0.625rem; /* matches tektonika reference space-y-2.5 */
}

/* ─── Special offers cards (landing) ───────────────────────────────
 * Mirrors SpecialOffersSection.tsx's inline `style={{ zIndex: 1 }}`
 * on the CardWrapper. Lifts each card above siblings so the hover
 * flex-grow expand doesn't sit behind the next card's rounded corner.
 */
.cms-offer-card {
  position: relative;
  z-index: 1;
}

/* ─── Home "Наши проекты" section (Round 11) ───────────────────────
 * Tokens from tektonika/src/components/ProjectsSection.tsx /
 * ProjectsFilter.tsx. Everything else (colours, spacing) already
 * ships in the prebuilt Tailwind bundle — these are the arbitrary
 * widths/heights the purger dropped.
 */
.h-\[220px\] { height: 220px; }
.max-h-\[65\%\] { max-height: 65%; }
.min-w-\[160px\] { min-width: 160px; }
@media (min-width: 768px) {
  .md\:h-\[680px\] { height: 680px; }
  .md\:min-h-\[389px\] { min-height: 389px; }
}

/* ─── [hidden] attribute must beat Tailwind `flex/grid` display classes ──
 * Tailwind's `display: flex` overrides the browser default `[hidden] {
 * display: none }`. We opt back into the hidden semantics for the home
 * projects panels (and any future panel that uses the attribute). */
[data-fx-hp-panel][hidden] { display: none !important; }
/* Plan G: card-carousel cards with no backing dp_project are emitted with
 * the bare `hidden` attribute so the renderer doesn't have to know about
 * Tailwind. Without this rule the card still occupies its flex slot. */
[data-fx="card-carousel"][hidden] { display: none !important; }

/* The horizontal axis belongs to the swipe handler in bindCardCarousel.
 *
 * Without this the fix reads as working and is not. Chrome watches the first
 * few pixels of a touch to decide whose gesture it is; on a `touch-action:
 * auto` element it claims a horizontal drag as a pan, fires `pointercancel`
 * and stops sending `pointermove` — measured here as exactly ONE pointermove
 * at 23px of travel, then cancel, on a card that never changed photo. Nothing
 * about that failure is visible in the JS: the listener is bound, it runs, and
 * the browser simply stops telling it where the finger went. A synthetic
 * dispatchEvent() harness never reproduces it, because synthetic events skip
 * the gesture arbiter entirely.
 *
 * `pan-y` hands the vertical axis back, so the page scrolls normally with a
 * finger on a card. `pinch-zoom` is kept deliberately: dropping it would take
 * zoom away from anyone who needs it, over a third of the home page. */
[data-fx="card-carousel"] { touch-action: pan-y pinch-zoom; }

/* ─── card-carousel controls: dots you can hit, arrows you can see ──
 *
 * The dots were a 2px-tall bar inside a `pointer-events: none` container —
 * drawn like a control, unreachable as one, on a card whose photo changed by
 * cursor position instead. Both halves are fixed here: the container keeps
 * its `pointer-events: none` (it stretches across the card, and making the
 * whole strip live would eat clicks meant for the project link) and only the
 * dots themselves take pointer events back.
 *
 * The hit box is grown with a transparent ::before rather than with padding
 * or a taller bar, so the dot row looks EXACTLY as it did — 2px bars, same
 * gap — while each one answers to a 30px-tall target. 30px clears the 24px
 * the WCAG 2.2 target-size rule asks for; the horizontal −3px bleed stops
 * neighbouring targets from touching, which would make the gap between two
 * bars belong to one of them. */
[data-fx-carousel-dots] > [data-fx-carousel-dot] { pointer-events: auto; cursor: pointer; position: relative; }
[data-fx-carousel-dot]::before { content: ''; position: absolute; inset: -14px -3px; }
[data-fx-carousel-dot]:focus-visible { outline: 2px solid hsl(var(--primary)); outline-offset: 4px; }

/* Arrows. Transparent by request — a scrim dark enough to keep a white
 * chevron legible over a bright render, and no more. The wrapper is inset-0
 * and inert; only the two buttons take pointer events, so the middle of the
 * card is still the project link.
 *
 * Hidden until the card is hovered, and ABSENT on a touch-primary device.
 * `@media (hover: …)`, not a width query: a touch laptop has a mouse and a big
 * screen, a phone in landscape has neither.
 *
 * Why a finger gets no arrows. It has two controls already — the swipe, and
 * dots that are now a 30px target — and no room for a third. The arrows are
 * vertically centred on the photo, and the chip list above them is as tall as
 * the project has chips: on /projects at 390 «Резиденция» wraps to four rows
 * and the left arrow lands on top of «ДЕТСКИЙ САД». Every fix that keeps both
 * is a guess about one card's content — nudge the arrows down and they hit the
 * home featured card's title, pad the chips and they wrap further still. A
 * pointer has no swipe, so it keeps the arrows; a finger has the gesture the
 * dots already imply.
 *
 * Focus-within keeps them reachable by keyboard, which a hover-only rule would
 * lock out entirely. */
.cms-card-nav { position: absolute; inset: 0; z-index: 25; pointer-events: none; }
.cms-card-arrow {
  position: absolute; top: 50%; transform: translateY(-50%);
  display: flex; align-items: center; justify-content: center;
  width: 40px; height: 40px; border-radius: 9999px;
  background: rgba(23, 23, 23, .22); color: #fff;
  cursor: pointer; pointer-events: auto;
  transition: opacity .2s ease, background-color .2s ease;
  /* Transparent as asked — you see the photograph through it — but DARKENED,
     so the white chevron has something to be white against. A flat scrim
     cannot do both, and the difference is measurable: screenshotting all six
     arrows on /projects @1440 and reading the glyph strokes against their own
     40px backdrop, a fixed 28% black scores 2.47–4.50:1 (worst case the left
     arrow over the pale tower), and `brightness(.6)` scores 4.44–6.42:1. Every
     arrow now clears the 3:1 WCAG asks of a non-text control, and it clears it
     over sky as well as over shadow, because the filter scales with whatever
     is behind it and a fixed scrim cannot.
     Unsupported browsers keep the scrim + the glyph shadow below, which is the
     rendering this replaces — degraded, not broken. */
  -webkit-backdrop-filter: brightness(.6);
          backdrop-filter: brightness(.6);
}
.cms-card-arrow--prev { left: 12px; }
.cms-card-arrow--next { right: 12px; }
/* The scrim alone is not enough on a bright render — these cards are mostly
   sky, and a white chevron on 28% black over a white cloud is a shape you have
   to look for. The shadow goes on the GLYPH, so the button stays as transparent
   as it was asked to be and the arrow stays legible on any photograph. */
.cms-card-arrow svg {
  width: 20px; height: 20px; display: block;
  filter: drop-shadow(0 1px 2px rgba(0, 0, 0, .55));
}
.cms-card-arrow:hover { background: rgba(23, 23, 23, .5); }
.cms-card-arrow:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
.cms-card-nav { opacity: 0; transition: opacity .2s ease; }
[data-fx="card-carousel"]:hover .cms-card-nav,
[data-fx="card-carousel"]:focus-within .cms-card-nav { opacity: 1; }
@media (hover: none) and (pointer: coarse) {
  .cms-card-nav { display: none; }
}
/* A narrow window on a machine that still has a mouse: the small cards are
 * ~154px wide at 768 and a 40px arrow would cover a quarter of the photo. */
@media (max-width: 767px) {
  .cms-card-arrow { width: 32px; height: 32px; }
  .cms-card-arrow--prev { left: 8px; }
  .cms-card-arrow--next { right: 8px; }
  .cms-card-arrow svg { width: 16px; height: 16px; }
}
/* Reduced motion drops the crossfade, not the gallery: the photo still
 * changes on request, it just cuts instead of dissolving. */
@media (prefers-reduced-motion: reduce) {
  [data-fx-carousel-slide] { transition: none !important; }
}

/* ─── Special offers cards — tokens missing from the prebuilt bundle ──
 * SpecialOffersSection.tsx card wrapper: `w-[170px] hover:w-[220px]
 * shrink-0 md:w-auto md:hover:w-auto md:flex-1 md:min-w-[120px]
 * md:hover:[flex-grow:1.8]` + `h-[200px] md:h-[260px]` + logo
 * `h-[14px]`. The bundle ships `md:w-auto` / `shrink-0` / `h-[200px]`;
 * everything else was purged. Because this file loads AFTER the bundle,
 * mobile-only widths are wrapped in `@media (max-width: 767.98px)` so
 * they don't beat the bundle's `md:w-auto` / `md:flex-1` at desktop.
 */
.h-\[14px\] { height: 14px; }
.h-\[200px\] { height: 200px; }
@media (max-width: 767.98px) {
  .w-\[170px\] { width: 170px; }
  .hover\:w-\[220px\]:hover { width: 220px; }
}
@media (min-width: 768px) {
  .md\:h-\[260px\] { height: 260px; }
  .md\:min-w-\[120px\] { min-width: 120px; }
  .md\:flex-1 { flex: 1 1 0%; }
  .md\:hover\:w-auto:hover { width: auto; }
  .md\:hover\:\[flex-grow\:1\.8\]:hover { flex-grow: 1.8; }
}

/* ─── Header stories overlay (shorts) ──────────────────────────────
 * Paired with `bindHeaderStories` in cms-effects.js. The 3 circular
 * header buttons open a fullscreen Instagram-style viewer that mirrors
 * the reference HeaderStories.tsx: image fills viewport, content is
 * layered on top via .cms-story-content (top row = bars + header,
 * bottom row = body + nav-wrap). All selectors are stable kit contracts
 * — see the JS for the DOM tree. */
.cms-story-overlay {
  position: fixed;
  inset: 0;
  z-index: 200;
  overflow: hidden;
  /* Match reference bg-foreground (#0d0d0d) */
  background: #0d0d0d;
  color: #fff;
  animation: cms-story-fade 280ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
/* Fade-out: added via JS before removal — mirrors reference AnimatePresence exit */
.cms-story-overlay--closing {
  animation: cms-story-fade-out 200ms cubic-bezier(0.7, 0, 0.84, 0) both;
}
@keyframes cms-story-fade {
  from { opacity: 0; }
  to   { opacity: 1; }
}
@keyframes cms-story-fade-out {
  from { opacity: 1; }
  to   { opacity: 0; }
}
.cms-story-media {
  position: absolute;
  inset: 0;
  z-index: 1;
  touch-action: pan-y;
  will-change: transform, opacity;
}
.cms-story-image {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  animation: cms-story-zoom 480ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes cms-story-zoom {
  from { opacity: 0; transform: scale(1.04); }
  to   { opacity: 1; transform: scale(1); }
}
.cms-story-image-gradient {
  position: absolute;
  inset: 0;
  pointer-events: none;
  /* Match reference: from-foreground/80 via-foreground/15 to-foreground/75
   * foreground token ≈ #0d0d0d on tektonika */
  background: linear-gradient(
    180deg,
    rgba(13, 13, 13, 0.8) 0%,
    rgba(13, 13, 13, 0.15) 45%,
    rgba(13, 13, 13, 0.75) 100%);
}
/* Half-screen tap zones below the content layer. */
.cms-story-zone {
  position: absolute;
  top: 0;
  bottom: 0;
  width: 50%;
  z-index: 10;
  background: transparent;
  border: 0;
  padding: 0;
}
.cms-story-zone--prev { left: 0; cursor: w-resize; }
.cms-story-zone--next { right: 0; cursor: e-resize; }
/* Content layer: full-height column, top + bottom rows pushed apart. */
.cms-story-content {
  position: relative;
  z-index: 20;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  height: 100%;
  padding: 1rem;
  pointer-events: none;
}
@media (min-width: 640px) { .cms-story-content { padding: 1.5rem; } }
@media (min-width: 768px) { .cms-story-content { padding: 2rem; } }
/* Top + bottom rows expose pointer events back to their controls. */
.cms-story-top,
.cms-story-bottom { pointer-events: auto; }
.cms-story-top {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}
/* Segmented progress bars across the top of the viewport. */
.cms-story-bars {
  display: flex;
  gap: 0.5rem;
}
.cms-story-bar {
  flex: 1;
  height: 4px;
  background: rgba(255, 255, 255, 0.2);
  border-radius: 9999px;
  overflow: hidden;
}
.cms-story-bar-fill {
  height: 100%;
  /* Track the host site's --primary token so themed accents stay in sync;
   * fall back to tektonika yellow for sites that don't define one. */
  background: hsl(var(--primary, 49 100% 50%));
  width: 0%;
  border-radius: 9999px;
  transition: width 80ms linear;
}
/* Header row: avatar + label/title on the left, action buttons right. */
.cms-story-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1rem;
}
.cms-story-meta {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}
.cms-story-cover {
  width: 2.75rem;
  height: 2.75rem;
  border-radius: 9999px;
  object-fit: cover;
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.35);
  flex-shrink: 0;
}
.cms-story-meta-text {
  display: flex;
  flex-direction: column;
  gap: 0.125rem;
  min-width: 0;
}
.cms-story-label {
  margin: 0;
  font-size: 0.75rem;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.7);
}
.cms-story-group-title {
  margin: 0;
  font-size: 1rem;
  line-height: 1.25;
  color: #fff;
}
@media (min-width: 640px) { .cms-story-group-title { font-size: 1.125rem; } }
.cms-story-actions {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}
.cms-story-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.75rem;
  height: 2.75rem;
  padding: 0;
  border-radius: 9999px;
  border: 1px solid rgba(255, 255, 255, 0.2);
  background: rgba(255, 255, 255, 0.1);
  color: #fff;
  cursor: pointer;
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  transition: background-color 200ms ease;
}
.cms-story-btn:hover { background: rgba(255, 255, 255, 0.2); }
.cms-story-btn svg {
  width: 1rem;
  height: 1rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.cms-story-btn polygon { fill: currentColor; stroke: currentColor; }
.cms-story-btn rect { fill: currentColor; stroke: none; }
/* Bottom row: body copy on the left, group nav chevrons on the right. */
.cms-story-bottom {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 1.5rem;
}
.cms-story-body {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  max-width: 48rem;
  min-width: 0;
}
.cms-story-counter {
  margin: 0;
  font-size: 0.75rem;
  letter-spacing: 0.32em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.6);
}
.cms-story-title {
  margin: 0;
  font-family: inherit;
  font-weight: 500;
  font-size: 1.75rem;
  line-height: 0.92;
  letter-spacing: -0.01em;
  max-width: 48rem;
}
@media (min-width: 640px) { .cms-story-title { font-size: 2.25rem; } }
@media (min-width: 768px) { .cms-story-title { font-size: 2.5rem; } }
.cms-story-desc {
  margin: 0;
  font-size: 0.875rem;
  line-height: 1.55;
  color: rgba(255, 255, 255, 0.8);
  max-width: 36rem;
}
@media (min-width: 640px) { .cms-story-desc { font-size: 1rem; } }
/* Group-level prev/next chevrons — desktop only, mirrors reference's
 * `hidden min-[1200px]:flex` guard. Click zones cover narrow viewports. */
.cms-story-nav-wrap {
  display: none;
  align-items: center;
  align-self: center;
  gap: 0.75rem;
}
@media (min-width: 1200px) { .cms-story-nav-wrap { display: flex; } }
.cms-story-nav {
  width: 3.5rem;
  height: 3.5rem;
}
.cms-story-nav svg { width: 1.25rem; height: 1.25rem; }


/* ─── Consultation sheet (right-side slide-in modal) ──────────────────
 * Tokens used by source/modules/domoplanner/seeds/tektonika/consultation_sheet.html.
 * The prebuilt /site/styles.css bundle ships .inset-y-0 + .rounded-2xl
 * + .bg-black\/80 but not the .translate-x-full / .rounded-l-3xl / arbitrary
 * .w-[460px] tokens the right-side sheet relies on, nor the responsive
 * sm:w-[460px] variant. */
.translate-x-full { transform: translateX(100%); }
.rounded-l-3xl { border-top-left-radius: 1.5rem; border-bottom-left-radius: 1.5rem; }
.w-\[460px\] { width: 460px; }
@media (min-width: 640px) {
  .sm\:w-\[460px\] { width: 460px; }
}

/* ─── Embla carousel (used by tek-project-construction and similar) ───
 * Explicit slide sizing — Tailwind arbitrary value `flex-[0_0_100%]` is not
 * shipped in /site/styles.css, so we provide deterministic sizing here. */
.embla__container {
  display: flex;
  height: 100%;
  touch-action: pan-y;
  backface-visibility: hidden;
}
.embla__slide {
  flex: 0 0 100%;
  min-width: 0;
  position: relative;
}

/* ─── Grid column-span utilities ──────────────────────────────────────
 * Pre-built /site/styles.css ships md:grid-cols-{2,3,4,5} but not
 * md:col-span-2 — used by tek-about-section's 1/3 + 2/3 bottom-row
 * card grid (Партнерам | НЕ НАШЛИ ТОГО). */
@media (min-width: 768px) {
  .md\:col-span-2 { grid-column: span 2 / span 2; }
}

/* Mobile-first ordering fix: the .md\:col-span-2 rule above (@768) is emitted
 * AFTER the .lg\:col-span-{3,2} block higher up (~line 134, @1024). At ≥1024px
 * BOTH media queries match with equal specificity, so source order decides —
 * and md-after-lg let .md\:col-span-2 win, collapsing the /projects featured
 * hero (`md:col-span-2 lg:col-span-3`) to 2/3 width with an empty 3rd column.
 * Re-assert the lg utilities here, after md, so lg wins at lg+. Elements that
 * use only md:col-span-2 (tek-about-section) lack the lg class and are
 * unaffected. */
@media (min-width: 1024px) {
  .lg\:col-span-3 { grid-column: span 3 / span 3; }
  .lg\:col-span-2 { grid-column: span 2 / span 2; }
}

/* ─── White-button on .bg-foreground card override ────────────────────
 * Legacy `.bg-foreground a { color: inherit }` in /site/styles.css makes
 * <a class="text-foreground"> render white-on-white inside a black card
 * (e.g. about-section's «Контакты» pill next to «Перезвоните мне»).
 * Bump specificity so explicit text-foreground links read correctly. */
.bg-foreground a.text-foreground { color: hsl(var(--foreground)); }

/* ─── Padding utilities missing from /site/styles.css ────────────────
 * Pre-built bundle ships md:pt-{14,24,36} but not md:pt-8 — used by
 * news-landing-section to sit tight under the about-section above. */
@media (min-width: 768px) {
  .md\:pt-8 { padding-top: 2rem; }
}

/* ─── back-to-top: round arrow fixed bottom-right; fades in past threshold.
 * Threshold + smooth-scroll handled by bindBackToTop in cms-effects.js. */
.cms-back-to-top {
  position: fixed;
  right: 1.5rem;
  bottom: 1.5rem;
  width: 3rem;
  height: 3rem;
  border: none;
  border-radius: 9999px;
  background: hsl(var(--primary));
  color: hsl(var(--primary-foreground));
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  opacity: 0;
  visibility: hidden;
  transform: translateY(8px);
  transition: opacity 200ms ease, visibility 0s linear 200ms,
              transform 200ms ease, background-color 200ms ease, filter 200ms ease;
  z-index: 90;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
.cms-back-to-top.is-visible {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
  transition: opacity 200ms ease, visibility 0s linear 0s,
              transform 200ms ease, background-color 200ms ease, filter 200ms ease;
}
/* Normalized to the unified gold-CTA hover: a background-independent
 * brightness(.9) fade (was the old `bg-primary/90` alpha, which composited with
 * the scrolling content behind this fixed FAB). filter is now in the transition
 * above so it eases like every other yellow button. */
.cms-back-to-top:hover  { background-color: hsl(var(--primary)); filter: brightness(.9); }
.cms-back-to-top:focus-visible {
  outline: 2px solid hsl(var(--ring, var(--primary)));
  outline-offset: 2px;
}
.cms-back-to-top svg { width: 1.25rem; height: 1.25rem; }
@media (max-width: 640px) {
  .cms-back-to-top { right: 1rem; bottom: 1rem; width: 2.75rem; height: 2.75rem; }
}

/* ─── cookie notice (bindCookieNotice) ───────────────────────────────
 * Fixed bottom-left card. Hidden by default — JS adds .is-visible only for a
 * visitor who hasn't consented yet, so returning visitors never see a flash
 * of it (see the binder comment: pages are served from PageCache). */
.cms-cookie-notice {
  position: fixed;
  left: 1.5rem;
  bottom: 1.5rem;
  z-index: 95;
  max-width: 26rem;
  display: flex;
  flex-direction: column;
  gap: 1rem;
  padding: 1.25rem 1.5rem;
  border-radius: 1rem;
  background: hsl(var(--foreground));
  color: hsl(var(--background));
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
  opacity: 0;
  visibility: hidden;
  transform: translateY(12px);
  transition: opacity 250ms ease, visibility 0s linear 250ms, transform 250ms ease;
}
.cms-cookie-notice.is-visible {
  opacity: 1;
  visibility: visible;
  transform: translateY(0);
  transition: opacity 250ms ease, visibility 0s linear 0s, transform 250ms ease;
}
.cms-cookie-notice__text {
  margin: 0;
  font-size: 0.8125rem;
  line-height: 1.6;
  color: hsl(var(--background) / 0.7);
}
.cms-cookie-notice__link {
  color: hsl(var(--background));
  text-decoration: underline;
}
.cms-cookie-notice__btn {
  align-self: flex-start;
  border: none;
  cursor: pointer;
  border-radius: 9999px;
  padding: 0.625rem 1.75rem;
  font-size: 0.8125rem;
  font-weight: 500;
  background: hsl(var(--primary));
  color: hsl(var(--primary-foreground));
  transition: filter 200ms ease;
}
.cms-cookie-notice__btn:hover { filter: brightness(.9); }
.cms-cookie-notice__btn:focus-visible {
  outline: 2px solid hsl(var(--ring, var(--primary)));
  outline-offset: 2px;
}
@media (max-width: 640px) {
  .cms-cookie-notice { left: 1rem; right: 1rem; bottom: 1rem; max-width: none; }
  /* Full-width on mobile, so lift the back-to-top FAB clear of the card.
     --cms-cookie-h is the card's measured height, published by the binder —
     the copy is admin-editable, so a fixed offset would eventually be wrong. */
  body.cms-cookie-visible .cms-back-to-top {
    bottom: calc(1rem + var(--cms-cookie-h, 9rem) + 0.75rem);
  }
}

/* ─── slider (bindSlider / modules/slider) ────────────────────────────
 * Generic mechanics for [data-fx="slider"]. The hero-specific frame +
 * crossfade timing live in the slider block's own scoped <style> (the css
 * column); these are the bundle-level fallbacks so any slider block works
 * even before its scoped CSS loads. */
[data-fx="slider"] [data-fx-slide] { transition: opacity .8s ease; }
/* Fade slides stack (absolute inset-0) and only crossfade opacity; opacity:0
 * does not stop hit-testing, so the last slide in DOM order would swallow every
 * click. Gate pointer events on the active slide (bundle fallback that mirrors
 * the slider block's scoped rule; slide mode is side-by-side, so exclude it). */
[data-fx="slider"]:not([data-fx-transition="slide"]) [data-fx-slide] { pointer-events: none; }
[data-fx="slider"]:not([data-fx-transition="slide"]) [data-fx-slide].is-active { pointer-events: auto; }
[data-fx="slider"][data-fx-arrows="false"] [data-fx-slider-prev],
[data-fx="slider"][data-fx-arrows="false"] [data-fx-slider-next] { display: none; }
/* Active indicator state = filled progress bar + brighter label (slider block's
 * scoped .cms-slider__ind* CSS). No button background — the old dot rule painted
 * the whole labelled indicator yellow. */

/* ── the phone region (slider_slides.crop_mobile) ─────────────────────────
 * A slide whose data-crop names a rectangle shows exactly that rectangle on a
 * phone. The four custom properties are published per image by bindSlider,
 * which is the only place the image's natural size is known.
 *
 * Scoped to the phone query and to a NON-EMPTY attribute, so this is inert for
 * every slide that has no region and at every width above 768px — where the
 * photo goes back to plain object-fit + data-focal with no JS in the path.
 *
 * The fallbacks matter: before the kit runs (or with JS off) the declarations
 * resolve to 100%/0, which is an ordinary cover-fitted image rather than a
 * mispositioned one. That is why object-fit stays `cover` — at the computed
 * size the image already has its natural aspect, so cover is a no-op, and in
 * the un-computed state it is the correct behaviour instead of a stretch. */
@media (max-width: 767px) {
  [data-fx="slider"] [data-fx-slide] img[data-crop]:not([data-crop=""]) {
    position: absolute;
    max-width: none;
    left:   var(--fx-crop-l, 0);
    top:    var(--fx-crop-t, 0);
    width:  var(--fx-crop-w, 100%);
    height: var(--fx-crop-h, 100%);
    object-fit: cover;
  }
}

/* ── frame shape, chosen per breakpoint in the Sliders admin ──────────────
 * data-frame-m / -t / -d carry a SLIDER_FRAME_SHAPES value from the slider
 * record: phone (<768), tablet (768–1023), desktop (≥1024).
 *
 * Empty is the default and matches NO rule below, so every slider that has not
 * been configured keeps whatever frame its own block CSS gives it, byte for
 * byte. That is the whole reason this is opt-in per value rather than a rule
 * that always applies: adding the three attributes to a block's markup changes
 * nothing until someone picks a shape.
 *
 * These deliberately outrank the consuming block's own class rule — the
 * selectors are two attributes deep (0,2,0) against a single class (0,1,0), so
 * an explicit admin choice wins over the block default regardless of load
 * order. "auto" reads the --fx-photo-ar that bindSlider publishes from the
 * active photo's naturalWidth/naturalHeight, which is the only shape that can
 * serve a gallery holding both panoramas and portraits.
 *
 * Bounds apply to "auto" and to nothing else. A ratio someone picked is a
 * statement about what this gallery should look like, and clamping it produces
 * a box that is not the ratio on the label — 3/2 chosen on a 1240px column
 * against a 78vh ceiling renders 1.77:1, and the admin has no way to see why.
 * "auto" is the opposite case: its shape comes from whichever photo was
 * uploaded, nobody chose it, and an unusually tall one would otherwise take the
 * whole screen. So it gets the 150px floor the arrow discs and dot bar sit in,
 * and a 78vh ceiling. */
@media (max-width: 767px) {
  [data-fx="slider"][data-frame-m="auto"]  { aspect-ratio: var(--fx-photo-ar, 4 / 3);
                                             min-height: 150px; max-height: 78vh; }
  [data-fx="slider"][data-frame-m="4/5"]   { aspect-ratio: 4 / 5; }
  [data-fx="slider"][data-frame-m="1/1"]   { aspect-ratio: 1 / 1; }
  [data-fx="slider"][data-frame-m="4/3"]   { aspect-ratio: 4 / 3; }
  [data-fx="slider"][data-frame-m="3/2"]   { aspect-ratio: 3 / 2; }
  [data-fx="slider"][data-frame-m="16/9"]  { aspect-ratio: 16 / 9; }
  [data-fx="slider"][data-frame-m="2/1"]   { aspect-ratio: 2 / 1; }
  [data-fx="slider"][data-frame-m="2.2/1"] { aspect-ratio: 2.2 / 1; }
  [data-fx="slider"][data-frame-m="3/1"]   { aspect-ratio: 3 / 1; }
  [data-fx="slider"][data-frame-m]:not([data-frame-m=""]) { width: 100%; }
}
@media (min-width: 768px) and (max-width: 1023px) {
  [data-fx="slider"][data-frame-t="auto"]  { aspect-ratio: var(--fx-photo-ar, 4 / 3);
                                             min-height: 150px; max-height: 78vh; }
  [data-fx="slider"][data-frame-t="4/5"]   { aspect-ratio: 4 / 5; }
  [data-fx="slider"][data-frame-t="1/1"]   { aspect-ratio: 1 / 1; }
  [data-fx="slider"][data-frame-t="4/3"]   { aspect-ratio: 4 / 3; }
  [data-fx="slider"][data-frame-t="3/2"]   { aspect-ratio: 3 / 2; }
  [data-fx="slider"][data-frame-t="16/9"]  { aspect-ratio: 16 / 9; }
  [data-fx="slider"][data-frame-t="2/1"]   { aspect-ratio: 2 / 1; }
  [data-fx="slider"][data-frame-t="2.2/1"] { aspect-ratio: 2.2 / 1; }
  [data-fx="slider"][data-frame-t="3/1"]   { aspect-ratio: 3 / 1; }
  [data-fx="slider"][data-frame-t]:not([data-frame-t=""]) { width: 100%; }
}
@media (min-width: 1024px) {
  [data-fx="slider"][data-frame-d="auto"]  { aspect-ratio: var(--fx-photo-ar, 4 / 3);
                                             min-height: 150px; max-height: 78vh; }
  [data-fx="slider"][data-frame-d="4/5"]   { aspect-ratio: 4 / 5; }
  [data-fx="slider"][data-frame-d="1/1"]   { aspect-ratio: 1 / 1; }
  [data-fx="slider"][data-frame-d="4/3"]   { aspect-ratio: 4 / 3; }
  [data-fx="slider"][data-frame-d="3/2"]   { aspect-ratio: 3 / 2; }
  [data-fx="slider"][data-frame-d="16/9"]  { aspect-ratio: 16 / 9; }
  [data-fx="slider"][data-frame-d="2/1"]   { aspect-ratio: 2 / 1; }
  [data-fx="slider"][data-frame-d="2.2/1"] { aspect-ratio: 2.2 / 1; }
  [data-fx="slider"][data-frame-d="3/1"]   { aspect-ratio: 3 / 1; }
  [data-fx="slider"][data-frame-d]:not([data-frame-d=""]) { width: 100%; }
}

/* drag-scroll (bindDragScroll): grab affordance for mouse users on an
 * overflowing row. .is-draggable is added by JS only when the row overflows,
 * so a fully-visible row keeps the default cursor. */
[data-fx="drag-scroll"].is-draggable { cursor: grab; }
[data-fx="drag-scroll"].is-dragging  { cursor: grabbing; user-select: none; }

/* A non-clickable project card (display_config.clickable=false — auto for
 * coming_soon, or set manually on the project page). The card shows its full
 * editorial content (name, subtitle, chips, price-per-setting); only its link
 * is neutralised so clicks do nothing. Coming-soon projects carry a «Скоро»
 * chip (display_config.chips) as their status marker. The legacy image-only
 * teaser (data-dp-coming-soon collapse via .cms-cs-hide + the .cms-cs-badge
 * pill) is retired; .cms-cs-badge stays hidden so any leftover seed pill markup
 * doesn't reappear. The attribute-scoped rule outranks Tailwind pointer utils. */
[data-dp-nolink] { pointer-events: none; cursor: default; }
.cms-cs-badge { display: none; }
/* Positive «Скоро» marker for a coming-soon project in the map sidebar (which
 * has no chip list to carry it). Independent of clickability. */
[data-dp-soon] .cms-cs-badge { display: inline-block; }

/* ── plan-crop: floor plans, everywhere one is shown ──────────────────────
 * The domoplanner feed pads every plan into a SQUARE canvas (1000², 1190² or
 * 1200²) and centres the drawing in it. Measured across all 619 distinct
 * plans on site 3: the ink covers a median 47.5% of the canvas width and
 * 64.5% of its height. So roughly half of every plan FILE is baked-in white
 * margin, and object-contain dutifully fits that MARGIN to the box — which is
 * why a plan reads tiny however large the img element is made. Sizing the
 * element is not enough; the margin has to be cropped.
 *
 * The box fills its tile and clips; the img fills the box and is zoomed.
 * Because the feed centres the drawing, a centred uniform zoom is equivalent
 * to a per-image trim.
 *
 * The 1.2 factor is measured, not chosen. The widest ink on ANY axis across
 * all 619 plans is 82.3% of the canvas, so 1.2 is the largest zoom that
 * cannot clip a single one of them (0.823 × 1.2 = 0.988); nothing is even
 * close to 90%. DO NOT raise it without re-measuring the live set — a floor
 * plan is an information graphic, and cropping one deletes data the buyer
 * needs. A unit test parses this number and fails above 1.215.
 *
 * Lives here rather than in each block's css column because five surfaces
 * share it (flat detail, catalog grid + list, similar flats, home/project
 * apartment rows), and because /site/styles.css is a frozen Tailwind build
 * that carries no scale-* utility at all. */
.cms-plan-crop {
  overflow: hidden;
  line-height: 0; /* kill the inline-image baseline gap under the img */
}
/* Card/row tiles have a fixed height — fill it and stay square. Every plan in
 * the feed is square, so aspect-ratio:1 is the shape, and object-fit:contain
 * below letterboxes safely if a non-square one ever arrives. */
.cms-plan-crop--tile { height: 100%; aspect-ratio: 1; }
/* The detail page is width-driven: fill the column up to a ceiling. */
.cms-plan-crop--page { width: 100%; max-width: 720px; aspect-ratio: 1; }
.cms-plan-crop img {
  width: 100%;
  height: 100%;
  object-fit: contain;
  transform: scale(1.2);
}

/* ── storerooms and parking spaces: a much emptier canvas ──
 * The 1.2 above is measured on APARTMENT plans, whose widest ink reaches 82.3%
 * of the canvas. A storeroom drawing does not: measured on the live image (all
 * 101 of this site's storerooms share one), ink fills 57.7% across and 56.3%
 * down. At 1.2 the drawing therefore sits at 69% of its box inside a wide dead
 * border — which is what "too small, too much empty space" meant.
 *
 * 1.45 is measured, not chosen, and the constraint is NOT the ink's width. The
 * drawing is off-centre — its box runs 244..935 across and 208..883 down a 1200
 * square — so the furthest ink edge from the centre is the TOP, 392px of the
 * 600px half-canvas. A centred zoom clips there first: 392 × 1.45 = 568 < 600,
 * a 5% margin. The hard ceiling is 600/392 = 1.53.
 *
 * DO NOT raise this past 1.5, and RE-MEASURE the moment the developer publishes
 * a second plan for any storeroom or parking space — the number above describes
 * one image, and a floor plan is an information graphic: cropping one deletes
 * what the buyer came to see. A unit test parses this number and fails above 1.5. */
.cms-plan-crop--object img { transform: scale(1.45); }

/* ── no plan published ──
 * 658 of site 3's 1278 flats ship no floor plan. The markup used to render
 * <img src=""> regardless, and an empty src is NOT an absent image: the element
 * keeps its whole layout box and paints nothing, so the detail card was a blank
 * 828px square and a studio-filtered catalog was a grid of blank tiles.
 *
 * The section stays and says so. It is a placeholder, not an error state — the
 * plan is simply not published yet — so it is quiet: the same muted-foreground
 * the tab labels use, on the same muted wash as the card hovers, no border of
 * its own (the card already has one) and no colour that reads as a warning.
 * Both halves ship in the markup and the renderer hides one, so the swap costs
 * no extra template and cannot get out of step with the <img> beside it. */
.cms-plan-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
  border-radius: 16px;
  background-color: hsl(var(--muted));
  color: hsl(var(--muted-foreground));
  text-align: center;
}
/* Geometry mirrors .cms-plan-crop exactly, so a flat with no plan occupies the
 * same box as one with a plan and a mixed row/grid stays aligned. */
.cms-plan-empty--page { width: 100%; max-width: 720px; aspect-ratio: 1; }
.cms-plan-empty--tile { height: 100%; aspect-ratio: 1; gap: 0; border-radius: 12px; }
.cms-plan-empty svg { width: 44px; height: 44px; opacity: .45; }
/* The tile is as small as 348px and carries no words — the caption belongs to
 * the card's own text below it, and a second line here would crowd it. */
.cms-plan-empty--tile svg { width: 28px; height: 28px; }
.cms-plan-empty__text {
  margin: 0;
  max-width: 22rem;
  font-size: 14px;
  line-height: 1.5;
}
/* display:flex above beats [hidden]/.hidden on source order, exactly as the tab
 * panels do a few rules down — same fix, same reason. */
.cms-plan-empty.hidden,
.cms-plan-crop.hidden { display: none !important; }

/* Flat detail «Планировка» / «План этажа» tabs.
 *
 * The button colours live here, not in the seed's class list, because the
 * switcher restyles a button on click and the public stylesheet is a frozen
 * Tailwind build stored in a DB column — a utility that was never compiled in
 * computes to nothing, silently, and no test can see it. Two CSS rules keyed on
 * an attribute the JS moves are immune to that. Colours are the same tokens the
 * old inline classes used (bg-foreground/text-background when active,
 * border-border/text-muted-foreground when not), so the look is unchanged. */
/* The `hidden` attribute alone does NOT hide the «Планировка» panel. That panel
 * carries Tailwind's `flex`, and `.flex { display: flex }` and `[hidden] {
 * display: none }` have identical specificity — the utility simply comes later
 * in the sheet and wins. So switching to «План этажа» would leave both panels
 * stacked on screen. Force it, once, for tab panels only. The `.hidden` CLASS
 * arm covers the storey_class="hidden" token: since 2026-08-20 the «План
 * этажа» panel carries `flex` too (it is the plan card's twin), so the same
 * utility-order bet would otherwise decide whether a flat without a storey
 * plan shows an empty framed card. */
[data-fx-tab-panel][hidden],
[data-fx-tab-panel].hidden { display: none !important; }

.cms-flat-tab {
  border-color: hsl(var(--border));
  color: hsl(var(--muted-foreground));
  background-color: transparent;
}
.cms-flat-tab:hover { background-color: hsl(var(--muted)); }
.cms-flat-tab[data-fx-tab-active] {
  background-color: hsl(var(--foreground));
  border-color: hsl(var(--foreground));
  color: hsl(var(--background));
}

/* The storey plan and the outline of this flat on it.
 *
 * Alignment is structural, not computed: the plan (<image>) and the flat
 * (<polygon>) are children of ONE <svg>, sharing its viewBox, so they are
 * registered by construction at every size — no JS, no naturalWidth read,
 * nothing to drift, and a tightened viewBox crops both together. There is no
 * CSS rule for .cms-storey-plan__img by design; it is a JS handle only
 * (bindStoreyPlan fills its href), and the svg's own geometry places it.
 *
 * Sizing (2026-08-20, a reversal): the frame below shares
 * .cms-plan-crop--page's square geometry, so the «План этажа» card is the
 * «Планировка» card's twin and the border CANNOT move on a tab switch. The
 * cost is the letterbox inside the square — plans ship in three shapes
 * (1190×840, 3182×4500, 4500×3182) and a portrait one paints dead white down
 * each side. The previous version bought that space back by letting the card
 * hug the drawing (width:fit-content), and what it paid was a frame that
 * resized on every switch between the tabs — which reads as the card jumping,
 * a worse defect than the white it saved. Fixed boundaries won. Not
 * .cms-plan-crop--page itself: that box crops (overflow:hidden + a measured
 * 1.2 zoom tuned to FLAT plans' margins) and would clip a storey drawing. */
.cms-storey-plan-frame {
  width: 100%;
  max-width: 720px;
  aspect-ratio: 1;
  display: flex;
  align-items: center;
  justify-content: center;
}
/* The svg carries width/height attributes, so it sizes exactly like a replaced
 * image: intrinsic size, shrunk to fit the square frame, ratio preserved.
 * width/height stay auto — pinning either would fight the ratio the viewBox
 * defines. (The old 78vh viewport cap is gone: the frame is bounded at 720px,
 * which already keeps a 4500px-tall plan from scrolling.) */
.cms-storey-plan {
  display: block;
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
}
/* Fill + stroke rather than dimming everything else: a floor plan is an
   information graphic and the neighbouring units are part of what the buyer is
   reading. Gold at 22% is legible over the drawing without hiding its lines. */
.cms-storey-plan__flat {
  fill: hsl(var(--primary) / 0.22);
  stroke: hsl(var(--primary));
  /* non-scaling-stroke, so stroke-width is 2 DEVICE pixels rather than 2
     viewBox units. It has to be: the same 2 units would be a 2px line on a
     1190px-wide plan and a hairline barely a third that on a 3182px one, and
     both sizes ship in this feed. Pinning to device px makes the outline read
     identically whatever the source image measures. */
  stroke-width: 2;
  vector-effect: non-scaling-stroke;
  stroke-linejoin: round;
}

/* Connection center: honeypot + form result/error states. */
.cms-hp { position: absolute !important; left: -9999px !important; width: 1px; height: 1px; overflow: hidden; }
/* Post-submit thank-you: replaces the <form>, so it must re-supply the form's
   own layout (flex-1 fill + horizontal padding it otherwise loses) and read as
   an intentional success state — centred, brand-gold check, colour inherited so
   it works on the light consultation panel and on dark form sections alike. */
.cms-form-result {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.75rem;
  min-height: 12rem;
  padding: 2rem 1.75rem 2.75rem;
  text-align: center;
  color: inherit;
}
.cms-form-result::before {
  content: "✓";
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 0 0 auto;
  width: 3.25rem;
  height: 3.25rem;
  margin-bottom: 0.25rem;
  border-radius: 9999px;
  background: hsl(var(--primary, 49 100% 50%));
  color: hsl(var(--primary-foreground, 0 0% 0%));
  font-size: 1.5rem;
  line-height: 1;
}
.cms-form-result__title {
  font-size: 1.375rem;
  font-weight: 600;
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: inherit;
}
.cms-form-result__msg {
  max-width: 24rem;
  font-size: 0.9375rem;
  line-height: 1.55;
  color: hsl(var(--muted-foreground, 215 14% 45%));
}
/* Success state in the consultation panel: the seed heading + intro now
   contradict "request sent", so hide them (the close button stays). */
[data-fx-consultation-panel].cms-form-done > div:first-child > h2,
[data-fx-consultation-panel].cms-form-done > p { display: none; }

.cms-form-error { margin: 0 0 0.75rem; font-size: 0.85rem; color: #c0392b; }

/* ─── image load placeholder ───────────────────────────────────────────────
   A baseline JPEG paints top-down as its bytes arrive, so an in-flight photo
   renders as a band of picture over a band of grey. cms-effects.js hides the
   <img> until its `load` fires and pins `.fx-img-skel` over its box.

   Both classes are written by JS and NEVER by a seed. If the kit fails to
   load, no image is hidden — same no-JS contract as `.fx-reveal-init` above.
   `body.fx-frozen *` already zeroes every animation, so the shimmer is a flat
   fill under Playwright and the visual gates stay deterministic. */
img.fx-img-pending { opacity: 0; }

/* Only added to a parent that is `position: static` AND has nothing already
   absolutely placed inside it; removed again the moment the image lands. */
.fx-img-host { position: relative; }

.fx-img-skel {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background-color: hsl(var(--muted, 220 14% 93%));
  background-image: linear-gradient(
    100deg,
    rgba(255, 255, 255, 0) 30%,
    rgba(255, 255, 255, 0.55) 50%,
    rgba(255, 255, 255, 0) 70%
  );
  background-repeat: no-repeat;
  background-size: 220% 100%;
  animation: fx-img-shimmer 1.4s ease-in-out infinite;
}
@keyframes fx-img-shimmer {
  from { background-position: 150% 0; }
  to   { background-position: -50% 0; }
}
/* A sweeping band reads as motion sickness to anyone who asked for less of
   it. Keep the placeholder, drop the sweep. */
@media (prefers-reduced-motion: reduce) {
  .fx-img-skel { animation: none; background-image: none; }
}
