/*
 * FONTS — all commercial fonts loaded locally from assets/fonts/, including
 * Jersey 15 now (was Google Fonts @import — removed for cookie-consent
 * compliance, see assets/cookie-consent-config.js: this was the only
 * third-party connection on the site that wasn't already either strictly
 * necessary or consent-gated).
 * Mazzard H (Regular 400, Medium 500, SemiBold 600, Bold 700) — body, UI, buttons
 * Neutral Face (Regular 400, Bold 700) — display type (WEB / SPHERE / ЗАТЯГУЄ)
 * Jersey 15 (Regular 400) — subhead accents
 */

/* ==========================================================================
   FONT FACES — local files
   ========================================================================== */

@font-face {
  font-family: 'Neutral Face';
  src: url('assets/fonts/NeutralFaceRegular.ttf') format('truetype');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Neutral Face';
  src: url('assets/fonts/NeutralFaceBold.ttf') format('truetype');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'Mazzard H';
  src: url('assets/fonts/MazzardH-Regular.ttf') format('truetype');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Mazzard H';
  src: url('assets/fonts/MazzardH-Medium.ttf') format('truetype');
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Mazzard H';
  src: url('assets/fonts/MazzardH-SemiBold.ttf') format('truetype');
  font-weight: 600;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Mazzard H';
  src: url('assets/fonts/MazzardH-Bold.ttf') format('truetype');
  font-weight: 700;
  font-style: normal;
  font-display: swap;
}

/* woff2 here (not .ttf like the two above) — this is the format Google
   Fonts actually serves for Jersey 15; ttf was never an option pulled
   from the source, only the format the other two happened to be
   delivered in. Single weight (400) is all Jersey 15 has. */
@font-face {
  font-family: 'Jersey 15';
  src: url('assets/fonts/Jersey15-Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/* Tiny5 (Regular, 400) — hero paragraph accent only (2026-10-15 font
   decision, see docs/DEPLOY.md's fonts section). Self-hosted, same
   pattern as Jersey 15 above: downloaded the font's own variable
   source from the google/fonts repo (ofl/tiny5), instantiated at its
   Regular defaults (wght:400, wdth:100, ROND/BLED/JITT:0) with
   fontTools varLib.instancer, then subset with fontTools subset to
   exactly the glyphs this site needs — Basic Latin, Latin-1
   punctuation, curly quotes/ellipsis, and Cyrillic incl. the four
   Ukrainian-specific letters (Ґ ґ Є є І і Ї ї) and Ё/ё — one 4.6KB
   file, no Google Fonts CDN dependency (works on file://, see
   docs/FRONTEND-NOTES.md). OFL.txt license sits alongside it. */
@font-face {
  font-family: 'Tiny5';
  src: url('assets/fonts/tiny5/Tiny5-Regular.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0020-007E, U+00A0-00FF, U+2018-201F, U+2026, U+2116, U+0400-045F, U+0490-0491, U+0401, U+0451;
}

/* ===================== DESIGN TOKENS ===================== */
:root {
  --color-bg-dark:      #060c0c;
  --color-accent:       #ff2828;
  --color-accent-dark:  #b10a0a;
  --color-bg-light:     #d9d9d9;
  --color-card-review:  #d3d2d2;
  --color-faq-expanded: #ececec;
  --color-border-dark:  #303637;
  --color-grey-dark:    #353535;
  --color-grey-slate:   #5a686d;
  --color-text-mid:     #606060;
  --color-text-light:   #b5b5b5;
  /* Muted/secondary body-text tokens (2026-10-14 contrast pass) — every
     place that rendered this role as a hand-typed literal (#5a686d,
     #575d61, #303637, rgba(90,104,109,x), #3d474a, #909090, #b5b5b5,
     #a8a8a8 — eight different literals for what were really 3 roles)
     now reads one of these three instead, so a future contrast tweak is
     one line, not an audit. Values are each the MINIMUM shift off the
     original hue that clears 4.5:1 (3:1 for the two large/bold
     instances) against every real background that role appears on,
     verified via the actual WCAG relative-luminance formula, not
     eyeballed — see docs/FRONTEND-NOTES.md or PROJECT-NOTES.md for the
     worked numbers if this needs revisiting.
       --color-text-muted      ΔE76 6.5  from the old #5a686d  (light bg)
       --color-text-muted-dark ΔE76 8.5  from the old #5a686d  (dark bg)
       --color-text-faint      ΔE76 37.5 from the old #b5b5b5  (light bg)
     --color-text-faint's shift is the one large jump of the three — its
     starting point (1.45:1) was far enough off that no small nudge
     reaches compliance; everything else stays a comparatively subtle
     retune. --color-text-light itself is untouched (a real, separate,
     already-compliant 9.6:1 dark-background usage on contacts.html —
     changing its value would have broken that, which is why this is a
     new token rather than a repurposed one). */
  --color-text-muted:      #4d585c;
  --color-text-muted-dark: #707d81;
  --color-text-faint:      #555555;
  --bg-navbar:          rgba(184,184,184,0.1);
  --bg-footer:          rgba(184,184,184,0.4);

  --font-body:    'Mazzard H', 'Manrope', system-ui, sans-serif;
  --font-display: 'Neutral Face', 'Anton', 'Impact', sans-serif;
  --font-hero:    'Jersey 15', 'Impact', sans-serif;
  /* 2026-10-15 font decision — .hero__paragraph ONLY (not the
     "FULL-SERVICE AGENCY" label, which stays --font-hero/Jersey 15 —
     that label is Latin-only text, Jersey 15 never had a Cyrillic
     problem there). var(--font-body) fallback, not a generic stack —
     this IS the font that was already live and correct here before
     this swap, so a Tiny5 load failure degrades to the previous
     known-good treatment instead of a bare system font. */
  --font-hero-accent: 'Tiny5', var(--font-body);

  --blur-navbar:   20px;
  --blur-footer:   50px;
  --radius-pill:   80px;
  --radius-card:   20px;
  --radius-btn:    10px;
  --radius-nav:    15px;
  --radius-cta:    40px;
  --radius-footer: 20px;

  --max-width: 1440px;
  --pad-x:     60px;
}

/* ===================== RESET ===================== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
[hidden] { display: none !important; }
html, body { position: relative; min-height: 100%; }
html { scroll-behavior: smooth; }
body {
  font-family: var(--font-body);
  background: var(--color-bg-dark);
  color: #fff;
  line-height: 1.5;
  overflow-x: clip;
}
img { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
ul, ol { list-style: none; }
button { font-family: inherit; cursor: pointer; border: none; background: none; }

/* UX hardening (2026-10-15), part 1 — decorative/UI text isn't meant
   to be selected/copied (headings, nav, buttons, chips, card labels,
   the hero and the quiz/cabinet/cases-drum sections). The opt-back-in
   block right after this one re-enables selection for actual content
   a visitor would plausibly want to copy — contact details, legal
   text, FAQ answers, review/case body copy — and every form field;
   it must stay textually AFTER this block so its equal-specificity
   class selectors win the cascade regardless of which happens to be
   more specific. */
h1, h2, h3, h4, h5, h6,
button,
.navbar__nav a, .navbar__logo, .navbar__lang, .navbar__cta, .navbar__lead,
.footer__nav a, .footer__social,
.hub-chip, .hub-tag, .hub-sort__btn,
.hero, .stages, .cases-drum,
.service-card__title, .service-card__cta,
.cases-grid-card__title, .review-card__name, .review-card__role {
  user-select: none;
  -webkit-user-select: none;
}
.contacts-info-row__value,
.faq-item__answer,
.review-card__quote,
.vmodal__quote,
.cases-grid-card__result,
.cases-grid-card__excerpt,
.cc-policy,
input, textarea {
  user-select: text;
  -webkit-user-select: text;
}

/* UX hardening, part 2 — images/icons/sprites: no native drag-to-copy
   ghost, no iOS long-press "save image" callout. Excludes <a> itself
   (an image-only link, e.g. the navbar logo, must stay a normal,
   draggable-as-a-link, long-press-able link — this only strips the
   browser's IMAGE-specific affordances from the <img>/<svg> itself). */
img, svg {
  -webkit-user-drag: none;
  -webkit-touch-callout: none;
}

/* UX hardening, part 3 — accidental-zoom prevention. touch-action
   kills double-tap-to-zoom (NOT pinch-zoom, which stays available —
   deliberately not touching user-scalable/maximum-scale, which would
   also disable pinch-zoom entirely: an accessibility regression, and
   iOS ignores that meta tag anyway). Input font-size ≥16px on mobile
   is handled per-field already via the existing mobile breakpoints
   (see each form's own @media block) — verified, not duplicated here.
   overflow-x: clip (not hidden) on both html and body: clip doesn't
   create a new scroll container or scroll-anchor the way hidden can,
   and was already verified to leave zero horizontal overflow anywhere
   in the full QA matrix. */
html { touch-action: manipulation; overflow-x: clip; }
body { overscroll-behavior-y: none; }

/* UX hardening, part 4 — tap highlight: the native flash on touch is
   replaced sitewide by this design system's own :hover/:focus-visible
   states, so it's redundant rather than informative. :focus-visible
   rings are untouched by this — only the tap-flash goes. */
* { -webkit-tap-highlight-color: transparent; }

/* ===================== GLOBAL GRID OVERLAY ===================== */
/* Fixed so it stays anchored to viewport as the user scrolls.
   z-index: 1 sits above section backgrounds (z-index: auto = step 6 in
   root stacking order) but below stage content and stacking contexts
   that explicitly declare z-index ≥ 2. */
body::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 1;
  image-rendering: pixelated;
  background-image:
    linear-gradient(to right,  rgba(255, 255, 255, 0.05) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
  background-size: 25vw 33.33vh;
  background-position: 0 0;
}

/* ===================== SHARED BUTTONS ===================== */
.btn--primary {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 14px 32px;
  background: var(--color-accent);
  color: #fff;
  font-family: var(--font-body);
  font-size: 13px;
  font-weight: 600;
  border-radius: var(--radius-btn);
  letter-spacing: 0.5px;
  transition: background 0.2s;
  white-space: nowrap;
}
.btn--primary:hover { background: var(--color-accent-dark); }

.btn--ghost {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 14px 32px;
  background: transparent;
  color: rgba(255,255,255,0.64);
  font-family: var(--font-body);
  font-size: 13px;
  font-weight: 500;
  border-radius: var(--radius-btn);
  border: 1px solid rgba(255,255,255,0.29);
  letter-spacing: 0.5px;
  transition: border-color 0.2s, color 0.2s;
  white-space: nowrap;
}
.btn--ghost:hover { border-color: rgba(255,255,255,0.64); color: #fff; }

/* ===================== NAVBAR ===================== */
.navbar {
  position: fixed;
  top: 23px;
  left: 21px;
  right: 21px;
  max-width: 1875px;
  height: 70px;
  display: flex;
  align-items: center;
  gap: 48px;
  padding: 0 24px;
  /* Default: vignette-blend — transparent, merges into dark backgrounds */
  background: rgba(255, 255, 255, 0.08);
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  /* Border kept invisible so it can animate in on light sections */
  border: 1px solid rgba(255, 255, 255, 0);
  box-shadow: none;
  border-radius: 80px;
  z-index: 100;
  /* top added here (not just where it actually changes, mobile's
     .is-scrolled rule below) so mobile inherits the full list instead of
     losing background/border-color/box-shadow's own smoothing — a
     separate transition declared only inside the mobile media query
     would replace this whole list there, not add to it. No-op on
     desktop itself, where top never changes. */
  transition: background 0.4s ease, border-color 0.4s ease, box-shadow 0.4s ease, top 0.35s ease, transform 0.35s ease;
}

/* Quiz "game mode" (assets/quiz.css/.js) slides the navbar off-screen
   while zoomed — transform lives in this list (not a separate rule)
   so it animates alongside the pill's existing background/border/top
   transitions instead of a second `transition` declaration silently
   replacing them for the same element. */
body.is-quiz-zoomed .navbar {
  transform: translateY(-140px);
}

/* Over light sections: premium dark glass pill — high contrast, visible separation */
.navbar.is-on-light {
  background: rgba(10, 10, 10, 0.80);
  border-color: rgba(255, 255, 255, 0.10);
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.30);
}

/* Wraps .navbar__logo + the mobile-only corner instance of the language
   dropdown. At desktop this is display:contents (same technique as
   .navbar__middle below) — the logo just sits in its normal position,
   and the corner lang widget stays hidden (desktop uses the instance in
   .navbar__closed-controls instead). At mobile it becomes a grid so the
   two can occupy the same cell and cross-fade — see the mobile override
   further down. */
.navbar__corner { display: contents; }

.navbar__logo {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  height: 44px;
  width: 44px;
  flex-shrink: 0;
  position: relative;
  /* logo.svg (the full wordmark) has a soft red glow baked into its own
     artwork, drawn via filters whose regions extend past the asset's
     own 120×44 viewBox — normally invisible (clipped to viewBox), but
     with nothing clipping *this* box, any mismatch between the box and
     the image's rendered size lets it bleed. Guards that regardless of
     the exact cause. */
  overflow: hidden;
  /* Was an instant 44→120px snap on .is-scrolled with no transition — the
     rest of the flex row (nav links, burger) jumped sideways in one frame.
     Smoothed to match the mark/wordmark cross-fade below. */
  transition: width 0.35s ease;
}
.navbar__logo-mark,
.navbar__logo-full {
  display: block;
  height: 44px;
  width: auto;
  transition: opacity 0.3s ease;
}
.navbar__logo-mark {
  width: 44px;
  opacity: 1;
  position: relative;
  object-fit: contain;
  border-radius: 0;
  background: transparent;
}
.navbar__logo-full {
  width: 120px;
  opacity: 0;
  pointer-events: none;
  position: absolute;
  top: 50%;
  left: 0;
  transform: translateY(-50%);
}
.navbar.is-scrolled .navbar__logo {
  width: 120px;
}
.navbar.is-scrolled .navbar__logo-mark {
  opacity: 0;
  pointer-events: none;
}
.navbar.is-scrolled .navbar__logo-full {
  opacity: 1;
  pointer-events: auto;
}

/* Wraps .navbar__nav + .navbar__closed-controls. At desktop this is
   display:contents — it drops out of the box model entirely, so its two
   children become direct flex items of .navbar itself, laid out exactly
   as if there were no wrapper (nav, then the lang/phone group pushed to
   the right by the group's own margin-left:auto, same as before this
   element existed). At mobile it becomes a real grid container so those
   same two children can occupy the same cell and cross-fade between
   each other — see the mobile override below. */
.navbar__middle { display: contents; }

.navbar__nav {
  display: flex;
  align-items: center;
  gap: 32px;
  list-style: none;
}
.navbar__nav a {
  font-family: var(--font-body);
  font-size: 14px;
  font-weight: 500;
  color: #fff;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  transition: opacity 0.2s;
}
.navbar__nav a:hover { opacity: 0.7; }
/* Current-page indicator (2026-10-15) — set by initActiveNavLink() in
   script.js, comparing each link's href against the current page's
   own filename. Only FAQ has a real nav-link destination today (every
   other item is a same-page anchor/modal trigger, which isn't "a
   page" to be "on"), but this is written generically for any future
   plain-page nav item. */
.navbar__nav a.is-active { color: var(--color-accent); }
.navbar__nav a.is-active:hover { opacity: 1; }

.navbar__closed-controls {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-left: auto;
}

/* Language dropdown — a widget wrapper (trigger button + listbox panel)
   rather than a plain toggle. Two instances share this markup/CSS/JS
   (see partials/header.js and initLangDropdown() in script.js): one
   permanently in .navbar__closed-controls (desktop), one in
   .navbar__corner (mobile, replacing the logo while the menu is open) —
   hidden/shown per breakpoint below, never both visible at once. */
.navbar__lang-widget {
  position: relative;
  display: flex;
  align-items: center;
}
/* Mobile-only instance — hidden at desktop; the closed-controls instance
   covers desktop instead. */
.navbar__lang-widget--corner { display: none; }

.navbar__lang {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  min-width: 44px;
  height: 44px;
  padding: 0 14px;
  background: transparent;
  border: none;
  cursor: pointer;
  color: white;
  font-family: var(--font-body);
  font-weight: 600;
  /* Was 13px (measured ~13px rendered, just under the 14px floor) —
     fixed-size 44×44 box has headroom, trivial/safe bump. */
  font-size: 14px;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  transition: opacity 0.15s;
  flex-shrink: 0;
  border-radius: 10px;
}
.navbar__lang:hover { opacity: 0.7; }
.navbar__lang-current { display: inline-block; }
.navbar__lang-chevron {
  width: 12px;
  height: 12px;
  flex-shrink: 0;
  transition: transform 0.2s ease;
}
/* Chevron flips to indicate the dropdown is open — same rotation this
   used to play as a one-off flourish on every cycle-click; now it's a
   genuine open/closed indicator instead. */
.navbar__lang-widget.is-open .navbar__lang-chevron {
  transform: rotate(180deg);
}

/* Dropdown panel — minimal and on-brand: same dark-glass fill/blur/
   border-colour the navbar pill uses over light sections
   (.navbar.is-on-light's own values, reused verbatim below) and the
   same 10px corner radius as .navbar__lang/.navbar__phone, rather than
   a heavier, more detached-looking panel. Anchored to the widget
   wrapper, not the trigger button, so it stays put regardless of the
   trigger's own size differences between the two instances. Sized for
   three 2-letter codes, not a generic menu — narrow, tight padding. */
.navbar__lang-menu {
  position: absolute;
  top: calc(100% + 6px);
  left: 0;
  min-width: 68px;
  margin: 0;
  padding: 4px;
  list-style: none;
  display: flex;
  flex-direction: column;
  gap: 2px;
  background: rgba(10, 10, 10, 0.85);
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  border: 1px solid rgba(255, 255, 255, 0.10);
  border-radius: 10px;
  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.35);
  z-index: 300;
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transform: translateY(-4px);
  transition: opacity 0.15s ease, visibility 0.15s ease, transform 0.15s ease;
}
.navbar__lang-widget.is-open .navbar__lang-menu {
  opacity: 1;
  visibility: visible;
  pointer-events: auto;
  transform: translateY(0);
}

/* Each option: the row itself stays a full ≥44px-tall hit area (that's
   the actual requirement — verified via getBoundingClientRect, not just
   assumed from this value) even though tight padding and small type
   keep it reading as compact rather than a generic menu item. Gap
   above (2px) plus the panel's own 4px padding is enough separation
   given each row is already a distinct, generously-sized target.
   Selected state is colour + a faint tint — no icon — matching the
   site's existing red-accent-for-active convention (same #FF2828 the
   chevron already uses) instead of a checkmark glyph. */
.navbar__lang-option {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: 0 8px;
  border-radius: 7px;
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  color: rgba(255, 255, 255, 0.6);
  cursor: pointer;
}
.navbar__lang-option:hover,
.navbar__lang-option.is-active {
  background: rgba(255, 255, 255, 0.08);
  color: #fff;
}
.navbar__lang-option[aria-selected="true"] {
  color: #ff2828;
  background: rgba(255, 40, 40, 0.08);
}
.navbar__lang-option[aria-selected="true"].is-active {
  background: rgba(255, 40, 40, 0.14);
}

/* Phone button — full pill, 44×44. Desktop: removed per spec — the
   right side now carries only the language switcher + CTA pill (see
   .navbar__closed-controls below, and the mobile override further down
   which brings this back for mobile's closed-controls row, where it
   still belongs). Was purely decorative (no tel: href, no JS listener)
   until a follow-up fix gave it data-open-modal (partials/header.js) —
   no real phone number exists anywhere on the site to back a genuine
   tel: link, so it opens the consultation modal instead, same as its
   .navbar__lead neighbour. Hiding it here on desktop has no
   behavioural side effect either way. */
.navbar__phone {
  display: none;
  width: 44px;
  height: 44px;
  border-radius: 10px;
  background: rgba(255,255,255,0.1);
  border: none;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  flex-shrink: 0;
  color: white;
  transition: background 0.2s;
}
.navbar__phone:hover { background: rgba(255,255,255,0.2); }
.navbar__phone svg { width: 18px; height: 18px; stroke: currentColor; }

/* CTA pill */
.navbar__cta {
  height: 44px;
  display: inline-flex;
  align-items: center;
  flex-shrink: 0;
  background: #FF2828;
  color: white;
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 13px;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  padding: 0 28px;
  border-radius: 10px;
  border: none;
  text-decoration: none;
  cursor: pointer;
  transition: background 0.2s;
  white-space: nowrap;
}
.navbar__cta:hover { background: #AE1B1B; }

/* Lead-request button — mobile-only equivalent of the CTA pill above
   (see partials/header.js), styled to match .navbar__phone exactly.
   Hidden here; shown and sized at the mobile breakpoint below. */
.navbar__lead { display: none; }
.navbar__lead:hover { background: rgba(255,255,255,0.2); }
.navbar__lead svg { width: 18px; height: 18px; stroke: currentColor; }

/* Burger — hidden on desktop; shown and sized at the mobile breakpoint
   below. Two stacked icons (hamburger / ×) cross-faded by .is-nav-open,
   same swap technique as the logo mark↔full above. */
.navbar__burger {
  display: none;
  position: relative;
  background: transparent;
  border: none;
  cursor: pointer;
  padding: 0;
  color: white;
}
.navbar__burger-icon {
  display: block;
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  transition: opacity 0.2s ease;
}
.navbar__burger-icon--close { opacity: 0; }
.navbar.is-nav-open .navbar__burger-icon--open { opacity: 0; }
.navbar.is-nav-open .navbar__burger-icon--close { opacity: 1; }

/* Scroll targets for the header's any-page nav items (ПОСЛУГИ/FAQ/
   ВІДГУКИ — #cases has its own equivalent rule already, scoped to
   cases-drum.css). .navbar is position:fixed (occupies no layout space)
   at top:23px/height:70px here, so landing on these with no offset
   leaves the pill overlapping the section's own heading. 110px clears
   its bottom edge (93px) with the same small buffer as #cases's rule —
   see cases-drum.css's own comment for the full measurement.
   #stages added for the hero "Розрахувати вартість" button's own
   href="#stages" — same reasoning (initHeroCtaScroll, script.js, still
   re-centers on .stages__screen itself after this lands). */
#services, #faq, #reviews, #stages {
  scroll-margin-top: 110px;
}


/* ===================== HERO ===================== */
.hero {
  position: relative;
  width: 100%;
  height: 100vh;
  min-height: 720px;
  background: url('assets/images/hero-bg.jpg') center/cover no-repeat #060c0c;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
}

/* Vignette — dark edges matching Figma reference */
.hero::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 1;
  background:
    /* Bottom fade — dissolves hero bg to solid #060c0c at the seam */
    linear-gradient(to bottom,
      transparent 0%,
      transparent 60%,
      rgba(6, 12, 12, 0.4) 80%,
      rgba(6, 12, 12, 0.9) 95%,
      #060c0c 100%
    ),
    /* Edge vignette — dark halo around the dome scene */
    radial-gradient(
      ellipse at center,
      transparent 40%,
      rgba(6, 12, 12, 0.6) 80%,
      rgba(6, 12, 12, 0.9) 100%
    );
}

/* Mobile-only hero image block — hidden on desktop, where .hero's own
   background handles it instead. See @media (max-width:768px) below. */
.hero__mobile-image { display: none; }
.hero__vignette { display: none; }

/* Fixed 1920×1080 canvas — JS scales it to fit viewport */
.hero__stage {
  position: relative;
  z-index: 2;
  width: 1920px;
  height: 1080px;
  transform-origin: center center;
  flex-shrink: 0;
  margin-top: 40px;
}

/* SEO/a11y fix (2026-10-16): wraps the two .hero__title-row divs in a
   real <h1> (index.html) — display:contents means this element
   generates no box of its own, so it's completely transparent to its
   position:absolute descendants' positioning context (they still
   resolve against .hero__stage exactly as before) and carries none of
   a default <h1>'s margin/font-size. Purely a semantic wrapper. */
.hero__h1 {
  display: contents;
}

/* Shared wordmark — Figma: Neutral Face 208.8px / 88% lh */
.hero__word {
  position: absolute;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 209px;
  line-height: 0.88;
  letter-spacing: 0;
  text-transform: uppercase;
  margin: 0;
  white-space: nowrap;
}

/* Figma X:103 Y:121 — Blend: Overlay */
.hero__word--web {
  top: 121px;
  left: 103px;
  color: #ffffff;
  mix-blend-mode: overlay;
}

/* Figma X:927 Y:122 — Blend: Overlay */
.hero__word--sphere {
  top: 122px;
  left: 927px;
  color: #ffffff;
  mix-blend-mode: overlay;
}

/* Figma X:94 Y:306 — solid red, no opacity reduction */
.hero__word--zatyaguye {
  top: 306px;
  left: 94px;
  color: #FF2828;
  opacity: 1;
}

/* Figma X:807 Y:203 — Jersey 15, 28.5px, lh 0.67 */
.hero__agency-label {
  position: absolute;
  top: 203px;
  left: 807px;
  font-family: 'Jersey 15', 'Impact', sans-serif;
  font-size: 28.5px;
  font-weight: 400;
  line-height: 0.67;
  color: #ffffff;
  text-transform: uppercase;
  letter-spacing: 0;
  text-align: center;
  margin: 0;
}

/* Figma X:1273 Y:306, W:523. Font history: was the Figma-specified
   'Jersey 15' (2026-10-15 bug report #1 — confirmed via canvas
   measureText, not assumed: Jersey 15 has ZERO Cyrillic coverage, so
   this whole paragraph was silently falling back to a generic sans
   the whole time), temporarily fixed to var(--font-body) as the safe
   interim, now replaced with var(--font-hero-accent)/Tiny5 (2026-10-15
   font decision — see docs/DEPLOY.md's fonts section for the license/
   self-hosting writeup, and the font-compare.png sheet this decision
   was made from). Same box/alignment/case as before this swap — only
   font-family/font-size/line-height change here; "FULL-SERVICE AGENCY"
   next to this stays on --font-hero/Jersey 15, untouched (Latin-only
   text, never had the Cyrillic problem). */
.hero__paragraph {
  position: absolute;
  top: 306px;
  left: 1273px;
  width: 523px;
  font-family: var(--font-hero-accent);
  /* 24px / 1.3 — empirically swept 16-30px at 1280 (the narrowest
     required desktop width, so the tightest wrap case): 23-28px all
     land exactly 4 lines (the max allowed), 29px+ overflows to 5. 24px
     sits comfortably mid-range, close to the pre-Tiny5 24.2px value
     for continuity. Box/position is fixed-width (not scaled by content),
     so horizontal clearance from ЗАТЯГУЄ (96-120px measured, well over
     the 24px minimum) doesn't move with this value — confirmed via the
     same sweep. Verified line count holds at 1280/1440/1920 (the whole
     stage scales uniformly via .hero__stage's transform, so the
     font-size:box-width ratio — and therefore the wrap — never changes
     across viewport widths, only the rendered pixel size does). */
  font-size: 24px;
  font-weight: 400;
  line-height: 1.3;
  color: #ffffff;
  text-transform: uppercase;
  letter-spacing: 0;
  text-align: right;
  margin: 0;
}
/* 2026-10-15 — explicit 4-line break, desktop (>=1024px; see the
   max-width:1023px override below for tablet/mobile). display:block
   (not <br>) per explicit spec — a screen reader still reads this
   <p> as one paragraph either way, unlike a <br>-laden one in some
   AT/browser combos. text-align/text-transform/color/letter-spacing
   all inherit from .hero__paragraph above, nothing to repeat here. */
.hero__line {
  display: block;
}
/* 8px extra gap between line 2 and line 3 specifically — explicit
   spec, "it separates the two sentences" (line 2 ends the first
   sentence, line 3 starts the second). Desktop-only — see the
   max-width:1023px override below, which zeroes this back out once
   lines flow naturally instead of being a fixed 4-line block. */
.hero__line--gap {
  margin-top: 8px;
}
/* Tablet/mobile (<1024px) — natural wrapping takes back over; real
   mobile's OWN separate .hero__paragraph treatment (inside the
   existing max-width:768px block further down — left-align, sentence
   case, 15px, etc.) still applies on top of this via the normal
   inheritance/cascade, this rule only ever touches display/wrap
   behavior, never color/case/alignment. */
@media (max-width: 1023px) {
  .hero__line {
    display: inline;
    text-wrap: pretty; /* explicit spec — avoids a single-word orphan on the last line where supported */
  }
  .hero__line--gap { margin-top: 0; } /* the 8px fixed-line gap doesn't mean anything once this flows naturally */
}

/* Figma: below dome — Y:800, horizontally centered on stage */
/* Figma X:683.7 Y:871.5 W:315 H:77 R:10 */
.hero__cta-primary {
  position: absolute;
  top: 871.5px;
  left: 683.7px;
  width: 315px;
  height: 77px;
  background: #FF2828;
  color: #fff;
  border: none;
  border-radius: 10px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-body);
  /* 25px (was 14px) — 2026-10-15 role swap, explicit spec: rendered
     >=16px on desktop. This box sits inside .hero__stage's
     transform:scale(), measured live at the smallest required width
     (1280) as low as 0.6667 (900px-tall test viewport — width-bound
     there; a real 1280-wide/short-ish window can't realistically go
     lower, since that would mean a viewport under 720px tall). 25*
     0.6667 = ~16.7px rendered, a real margin over the 16px floor, not
     a knife's-edge fit. 1440/1920 only ever scale up from there. */
  font-size: 25px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  text-decoration: none;
  padding: 0 16px;
  cursor: pointer;
  transition: background 0.2s;
  white-space: nowrap;
  box-sizing: border-box;
}
.hero__cta-primary:hover { background: #AE1B1B; }

/* Figma X:1026.4 Y:871.5 W:203 H:77 R:10 */
.hero__cta-secondary {
  position: absolute;
  top: 871.5px;
  left: 1026.4px;
  width: 203px;
  height: 77px;
  background: transparent;
  color: #fff;
  border: 0.87px solid #fff;
  border-radius: 10px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-body);
  /* 25px — same value/reasoning as .hero__cta-primary just above. */
  font-size: 25px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  text-decoration: none;
  padding: 0 16px;
  cursor: pointer;
  transition: all 0.2s;
  white-space: nowrap;
  box-sizing: border-box;
}
.hero__cta-secondary:hover { background: rgba(255,255,255,0.05); }

/* ===================== STATS ROW ===================== */
/*
 * Frame 1000003148 — 1920×463 (Figma node 85:5612)
 * All child coordinates absolute within the 1920×463 stage.
 * JS scales .stats__stage uniformly; CSS aspect-ratio sets section height.
 */
.stats {
  position: relative;
  width: 100%;
  /* Height = scaled stage height; JS also sets this on resize */
  height: calc(463 / 1920 * 100vw);
  min-height: 160px;
  padding-bottom: 60px;
  background: #060c0c;
  overflow: hidden;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  margin-bottom: 0;
}

/* Top fade — masks any remaining seam from the hero above */
.stats::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 80px;
  background: linear-gradient(to bottom, #060c0c 0%, transparent 100%);
  pointer-events: none;
  z-index: 2;
}

/* Fixed 1920×463 canvas — JS scales uniformly to fill section width */
.stats__stage {
  position: relative;
  z-index: 1;
  width: 1920px;
  height: 463px;
  /* top-center: scale grows downward, no vertical drift */
  transform-origin: top center;
  flex-shrink: 0;
}

/* Every item: absolute in stage, starts hidden for scroll animation */
.stats__item {
  position: absolute;
  opacity: 0;
  transform: translateY(30px);
  transition: opacity 0.6s ease-out, transform 0.6s ease-out;
}

.stats__item.is-visible {
  opacity: 1;
  transform: translateY(0);
}

@media (prefers-reduced-motion: reduce) {
  .stats__item { opacity: 1; transform: none; transition: none; }
}

/* ── Per-item Figma positions ── */

/* Item 1: "7+" at Y=209.67  "років на ринку" at Y=261.17 */
.stats__item--1 { left: 127.83px; top: 209.67px; }

/* Item 2: "100%" at Y=206.7 — was Y=146.61 to leave room for a
   FULL-SERVICE AGENCY mini-label above the number (now removed; all 4
   items share the same plain number+label component, see below). */
.stats__item--2 { left: 442.36px; top: 206.7px; }

/* Item 3: "27" at Y=206.7 */
.stats__item--3 { left: 777.65px; top: 206.7px; }

/* Item 4: "140+" at Y=206.7 */
.stats__item--4 { left: 1079.32px; top: 206.7px; }

/* ── Child elements: absolutely stacked within each item ── */

/* Big numbers: Neutral Face Bold 45.069px lh:0.95 */
.stats__number {
  position: absolute;
  left: 0;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 45.069px;
  line-height: 0.95;
  text-transform: uppercase;
  white-space: nowrap;
}

/* item--1: number top=0 (number IS the item anchor), red */
.stats__item--1 .stats__number { top: 0; color: #ff2828; }

/* items 2–4: number top=0 (number IS the item anchor) */
.stats__item--2 .stats__number,
.stats__item--3 .stats__number,
.stats__item--4 .stats__number { top: 0; color: #ffffff; }

/* Labels: Neutral Face Bold 19.701px lh:0.95 rgba(255,255,255,0.42) */
.stats__label {
  position: absolute;
  left: 0;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 19.701px;
  line-height: 0.95;
  color: rgba(255, 255, 255, 0.42);
  text-transform: uppercase;
  white-space: nowrap;
}

/* all 4 items: label at 258.2 − 206.7 = 51.5px below item top */
.stats__item--1 .stats__label,
.stats__item--2 .stats__label,
.stats__item--3 .stats__label,
.stats__item--4 .stats__label { top: 51.5px; }

/* ===================== TEAM ===================== */
.team {
  position: relative;
  width: 100%;
  /* Scroll budget = 95vh of scrubbing (was 150vh) — cut ~37% so the pinned
     sequence resolves in noticeably less scroll distance; see mobile: 35vh. */
  height: 195vh;
  padding: 0;
  margin: 0;
  background: #060c0c;
  /* z-index lifts the whole section (and its ::before cover) above the
     global body::before grid overlay (also z-index:1, but earlier in the
     DOM) — this is the ONLY section without the grid. */
  z-index: 1;
}

/* Opaque cover that hides the global grid on this section only. Spans the
   full scroll budget; the sticky child always fills the viewport while the
   section is on screen, so this is never seen uncovered mid-section. Top/
   bottom fade lets the neighbouring sections' grid dissolve in over ~90px
   instead of stopping at a hard line (same trick as .stats::before). */
.team::before {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(
    to bottom,
    transparent 0,
    #060c0c 90px,
    #060c0c calc(100% - 90px),
    transparent 100%
  );
}

.team__sticky {
  position: sticky;
  top: 0;
  height: 100vh;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  padding: 0 40px;
}

.team__stage {
  position: relative;
  z-index: 1;
  width: 1920px;
  height: 1080px;
  transform-origin: center center;
  flex-shrink: 0;
  will-change: transform, opacity;
  opacity: var(--team-stage-opacity, 1);
  /* Short transitions smooth the scroll-scrubbed values frame-to-frame
     (belt-and-braces with the JS lerp) without perceptible lag. */
  transition: opacity 0.18s ease-out, transform 0.14s ease-out;
  /* Distance the two heading layers travel during the slide-in entrance
     (white from the left, grey from the right). Overridden for mobile. */
  --head-slide: 240px;
}

.team__word {
  position: absolute;
  font-family: var(--font-display);
  font-size: 180px;
  font-weight: 700;
  line-height: 0.95;
  text-transform: uppercase;
  white-space: nowrap;
  will-change: transform, opacity;
  transition: transform 0.14s ease-out, opacity 0.14s ease-out;
}
/* ENTRANCE — the reveal JS scrubs --head-in (slide) and --head-opacity
   (fade, which resolves earlier so white/grey read as distinct colours
   through the travel). White layer slides in from the left, grey from the
   right; both settle into their offset resting positions. Both custom
   props default to 1 so the heading is correct with JS off / reduced
   motion. */
.team__word--odna,
.team__word--komanda   { transform: translateX(calc((var(--head-in, 1) - 1) * var(--head-slide))); }
.team__word--napryamiv,
.team__word--try       { transform: translateX(calc((1 - var(--head-in, 1)) * var(--head-slide))); }
.team__word { opacity: var(--head-opacity, 1); }

.team__word--odna      { left: 120px;  top: 329px; color: #ffffff; }
.team__word--napryamiv { left: 772px;  top: 329px; color: var(--color-text-muted-dark); }
.team__word--komanda   { left: 120px;  top: 530px; color: #ffffff; }
/* left moved in from 1381px (old "ТРИ"'s position, 413px wide) to
   1320px — "ВЕСЬ" measures ~511px at this font-size/weight, and 1381
   would run it past the 1920px stage's right edge (clipped by
   .team__sticky's overflow:hidden). 1320px clears both KOMANDA's own
   right edge (~1128px) and the stage boundary with margin to spare. */
.team__word--try       { left: 1320px; top: 530px; color: var(--color-text-muted-dark); }

/* ===================== SERVICES ===================== */
.services__stage {
  position: absolute;
  top: calc(50% + 46px);
  left: 50%;
  width: 1920px;
  height: 1080px;
  transform: translate(-50%, -50%);
  transform-origin: center center;
  z-index: 3;
  pointer-events: none;
  will-change: transform;
  transition: transform 0.14s ease-out;
}

.service-card {
  position: absolute;
  pointer-events: auto;
  width: 397px;
  height: 437px;
  padding: 28px;
  border-radius: 60px;
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  gap: 16px;
  text-decoration: none;
  color: #fff;
  border: 1px solid #3d3d3d;
  opacity: 0;
  /* `scale` is the individual transform property — independent of
     `transform` (which the reveal scrubs via --reveal-y), so the press
     bounce can transition on its own without lagging the scroll reveal.
     Old engines that don't support it just skip the bounce (glow still
     fires). */
  scale: 1;
  transition:
    scale 0.22s cubic-bezier(0.34, 1.7, 0.5, 1),
    transform 0.14s ease-out, opacity 0.14s ease-out,
    background 0.35s ease, border-color 0.35s ease, box-shadow 0.3s ease;
  will-change: transform, opacity;
  cursor: pointer;
}

.service-card--design,
.service-card--center,
.service-card--traffic {
  background: rgba(39, 39, 39, 0.4);
  backdrop-filter: blur(36px);
  -webkit-backdrop-filter: blur(36px);
  box-shadow: 0 40px 80px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.08);
}

.service-card--design {
  left: 287px;
  top: 281px;
  z-index: 1;
}

.service-card--center {
  left: 762px;
  top: 259px;
  z-index: 3;
  border-color: #3d3d3d;
}

.service-card--traffic {
  left: 1227px;
  top: 277px;
  z-index: 2;
}

.service-card__illustration {
  /* Full-bleed: break out of 28px card padding to reach card edges */
  width: calc(100% + 56px);
  margin: -28px -28px 0;
  height: 250px;
  flex-shrink: 0;
  overflow: hidden;
  border-radius: 60px 60px 0 0;
  mix-blend-mode: color-dodge;
  opacity: 0.55;
}
.service-card__illustration img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center top; /* default: good for Розробка (pattern in upper area) */
}

/* Cursor (Дизайн) and crown (Трафік) are centered in their source squares —
   center vertically so the subject sits mid-illustration, not near the bottom */
.service-card--design .service-card__illustration img,
.service-card--traffic .service-card__illustration img {
  object-position: center center;
}

/* Трафік crown: full opacity per Figma (no opacity class on node 1:3075) */
.service-card--traffic .service-card__illustration {
  opacity: 1;
}

.service-card__body {
  flex-grow: 1;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.service-card__body--center {
  justify-content: center;
}

.service-card__title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 36px;
  line-height: 1;
  text-transform: uppercase;
  color: #fff;
  margin: 0;
}
.service-card__description {
  font-family: var(--font-body);
  font-weight: 400;
  font-size: 13px;
  line-height: 1.5;
  color: rgba(255, 255, 255, 0.75);
  margin: 0;
}

.service-card__cta {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-top: auto;
  font-family: var(--font-body);
  font-weight: 500;
  font-size: 14px;
  color: #fff;
  transition: gap 0.3s ease;
}
.service-card__cta svg { transition: transform 0.3s ease; }

.service-card--design:hover,
.service-card--center:hover,
.service-card--traffic:hover {
  background: #ff2828;
  border-color: #ff2828;
  box-shadow:
    0 60px 100px rgba(255, 40, 40, 0.35),
    inset 0 1px 0 rgba(255, 255, 255, 0.15);
  transition:
    transform 0.5s cubic-bezier(0.2, 0.8, 0.2, 1),
    scale 0.22s cubic-bezier(0.34, 1.7, 0.5, 1),
    background 0.35s ease,
    border-color 0.35s ease,
    box-shadow 0.5s ease;
}
.service-card--design:hover  { transform: translateY(-24px) rotate(-5deg) !important; }
.service-card--center:hover  { transform: translateY(-56px) rotate(0) !important; }
.service-card--traffic:hover { transform: translateY(-24px) rotate(5deg) !important; }
.service-card:hover .service-card__cta { gap: 12px; }
.service-card:hover .service-card__cta svg { transform: translateX(4px); }

/* PRESS FEEDBACK — quick scale-up "bounce" + accent glow while the card is
   held (works for touch and mouse via :active). Uses the `scale` property
   so it never touches the reveal's `transform`. Springy easing on `scale`
   (set on .service-card) gives the settle-back on release. */
.service-card:active {
  scale: 1.035;
  box-shadow:
    0 0 0 1px rgba(255, 40, 40, 0.45),
    0 18px 50px rgba(255, 40, 40, 0.28),
    0 40px 80px rgba(0, 0, 0, 0.5),
    inset 0 1px 0 rgba(255, 255, 255, 0.12);
}

/* (No hover-dim on .team__stage any more — the reveal fades the heading
   fully out behind the cards by the end of the sequence, so re-showing it
   on card hover would just make stray letters flash back.) */

/* Mobile-only swipe-carousel dot indicators — see @media (max-width:768px)
   below for the actual styling; hidden on desktop where all 3 cards are
   simply laid out side by side with no need to indicate an "active" one. */
.services__dots { display: none; }

/* SWIPE-AFFORDANCE HINT — one-shot, JS-triggered once per session after the
   scroll sequence finishes (and only if the user hasn't swiped yet). It
   drives the row's own scrollLeft from JS, so no keyframes here; this rule
   just documents the state class the JS toggles for any future styling. */
.services__stage.is-hinting { scroll-snap-type: none; }

@media (prefers-reduced-motion: reduce) {
  /* No pinned scrub — collapse the scroll budget so the section is a
     single static screen (mobile block below refines this further). */
  .team { height: 100vh; }
  .service-card { opacity: 1 !important; transform: none !important; transition: background 0.3s ease, border-color 0.3s ease; }
  .service-card:hover { transform: none !important; }
  .service-card:active { scale: 1; }         /* keep the glow, drop the bounce */
  .team__stage { opacity: 0.3 !important; }  /* static: heading stays readable behind the cards */
  .team__word { transition: none !important; }
}

/* ===================== CASES ===================== */
/* Moved out entirely — see cases-drum/cases-drum.css for the real
   WebGL 3D drum that replaced the old CSS/transform orbital carousel
   that used to live here (live review #2). Nothing in this file
   styles the cases section any more. */

/* ===================== LIGHT WRAPPER ===================== */
.light-wrapper {
  background: var(--color-bg-light);
  border-radius: var(--radius-pill);
  margin: 40px;
  overflow: clip;
  clip-path: inset(0 round var(--radius-pill));
  position: relative;
  z-index: 2;
}

/* ===================== REVIEWS ===================== */
/* ===================== REVIEWS ===================== */
.reviews {
  overflow: hidden;
  position: relative;
}

.reviews__stage {
  width: 1920px;
  transform-origin: top left;
  box-sizing: border-box;
  padding: 120px 0;
  display: flex;
  flex-direction: column;
  gap: 50px;
}

.reviews__header {
  padding: 0 200px;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.reviews__label {
  font-family: var(--font-body);
  font-size: 20px;
  font-weight: 600;
  color: var(--color-text-muted);
  line-height: 0.97;
}

.reviews__heading {
  font-family: var(--font-display);
  font-size: 55.54px;
  font-weight: 700;
  color: #353535;
  text-transform: uppercase;
  line-height: 1;
}

/* Carousel — margin-left hard-clips the left edge; padding-right lets card 4 peek 163px right */
.reviews__carousel-wrap {
  margin-left: 200px;
  padding-right: 200px;
  overflow: hidden;
}

.reviews__track {
  display: flex;
  gap: 19px;
  will-change: transform;
}

/* Individual card */
.review-card {
  flex-shrink: 0;
  width: 500px;
  height: 440px;
  border-radius: 20px;
  background: var(--color-card-review);
  box-sizing: border-box;
  padding: 20px 60px 60px;
  position: relative;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  gap: 9px;
}
/* Peeking card (right-edge partial): clicking it advances the carousel.
   Descendants get pointer-events:none so clicks always reach the card element. */
.review-card--peeking {
  cursor: pointer;
}
.review-card--peeking * {
  pointer-events: none;
}

/* Avatar initials circle */
.review-card__top {
  display: flex;
  align-items: center;
  gap: 6px;
}

.review-card__avatar {
  flex-shrink: 0;
  width: 39px;
  height: 39px;
  border-radius: 50%;
  background: #5a686d;
  display: flex;
  align-items: center;
  justify-content: center;
}

.review-card__initials {
  font-family: var(--font-display);
  font-size: 12px;
  font-weight: 700;
  color: #fff;
  text-transform: uppercase;
  line-height: 1;
  letter-spacing: 0.5px;
}

.review-card__meta {
  display: flex;
  flex-direction: column;
  gap: 3px;
}

.review-card__name {
  font-family: var(--font-display);
  font-size: 15px;
  font-weight: 700;
  color: #445156;
  text-transform: uppercase;
  line-height: 1.32;
}

.review-card__role-row {
  display: flex;
  align-items: center;
  gap: 4px;
}

/* #606060 measured 4.17:1 against the card's #d3d2d2 background — under
   the 4.5:1 WCAG AA floor for text this small (9px desktop, 14px
   mobile; neither qualifies as "large text" so the full 4.5:1 applies).
   #4a4a4a (5.87:1) keeps the same grey, just dark enough to clear it —
   one of 4 real contrast failures found in this block (see
   .review-card__quote below for the second fixed one; the other two,
   .review-card__quote-mark/__link, were also fixed this way at first
   but reverted to the brand red — an accepted exception, see there). */
.review-card__role,
.review-card__company-name {
  font-family: var(--font-body);
  font-size: 9px;
  font-weight: 600;
  color: #4a4a4a;
  line-height: 1.3;
}

.review-card__role-dot {
  flex-shrink: 0;
  width: 1.5px;
  height: 1.5px;
  border-radius: 50%;
  background: #4a4a4a;
}

/* Quote — scrollable within fixed card height, thin red scrollbar matching
   FAQ. position:relative so .review-card__quote-fog below (an absolutely-
   positioned child) anchors to THIS box's own visible edge rather than
   scrolling away with the content — same relationship FAQ gets from its
   wrap/scroll split, just on one element here instead of two since this
   card never had a reason to separate them. */
.review-card__quote-wrap {
  position: relative;
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: #ff2828 transparent;
}
.review-card__quote-wrap::-webkit-scrollbar { width: 3px; }
.review-card__quote-wrap::-webkit-scrollbar-track { background: transparent; }
.review-card__quote-wrap::-webkit-scrollbar-thumb { background: #ff2828; border-radius: 10px; }

/* Scroll fog — bottom gradient, same idea and trigger as
   .faq-item__scroll-fog: JS adds .scrolls to the wrap exactly when the
   quote's scrollHeight exceeds its visible height, so this only ever
   appears on a genuinely truncated quote. Fades to the card's own
   background (--color-card-review), not white, so it blends with the
   actual card instead of leaving a visible seam. */
.review-card__quote-fog {
  display: none;
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 32px;
  background: linear-gradient(to bottom, transparent, var(--color-card-review));
  pointer-events: none;
}
.review-card__quote-wrap.scrolls .review-card__quote-fog { display: block; }

/* #5a686d measured 3.83:1 against #d3d2d2 — fails the 4.5:1 floor at
   any size this text actually renders at (20px/600-weight desktop
   isn't quite WCAG's "large text" cutoff — 18.66px BOLD/700 or 24px
   normal — and mobile shrinks it to 13px regardless). #3d474a (6.33:1)
   is the same blue-grey hue, just dark enough to clear it. */
.review-card__quote {
  font-family: var(--font-body);
  font-size: 20px;
  font-weight: 600;
  color: var(--color-text-muted);
  line-height: 1;
}

/* #ff2828 (the site's own brand red) measured 2.49:1 against #d3d2d2 —
   well under even the 3:1 large-text floor. Tried the darker #b10a0a
   (4.77:1, already used elsewhere in this exact component —
   .review-card__video-btn's own border-color) first; reverted on
   request — brand-red consistency wins over the contrast ratio here
   specifically. Accepted exception, left as-is deliberately, not an
   oversight. (.review-card__link, the other half of this exception,
   was removed entirely — see its own removal note near
   .review-card__quote-wrap's mobile rule.) */
.review-card__quote-mark {
  color: #ff2828;
  font-family: var(--font-body);
  font-size: 20px;
  font-weight: 700;
}

/* "▶ Відео відгук" pill — absolute top-right of card */
.review-card__video-btn {
  position: absolute;
  top: 20px;
  right: 18px;
  width: 144px;
  height: 50px;
  border-radius: 121px;
  background: #ff2828;
  border: 1px solid #b10a0a;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 0 14px;
  cursor: pointer;
  box-sizing: border-box;
  overflow: hidden;
  gap: 6px;
}
.review-card__video-btn:hover { background: #e01f1f; }

.review-card__video-icon {
  flex-shrink: 0;
  width: 23px;
  height: 23px;
}

.review-card__video-label {
  font-family: var(--font-body);
  font-size: 15px;
  font-weight: 600;
  color: #fff;
  line-height: 0.97;
  white-space: nowrap;
}

/* ═════════════════════════════════════════════════════════════════
   VIDEO REVIEW — lite YouTube embed (2026-10-15). See assets/hub.js'
   hubRenderVideoBlock/hubInitVideoReviews for the markup/behavior this
   styles; reviews.html's hub grid is the current caller.
   ═════════════════════════════════════════════════════════════════ */
.review-card__video {
  position: relative;
  width: 100%;
  border-radius: 20px; /* same as .review-card's own radius */
  overflow: hidden;
  background: #000;
  /* aspect-ratio reserves the box BEFORE the poster image loads — no
     layout shift, explicit requirement. 16/9 default; --vertical
     overrides to 9/16 below. */
  aspect-ratio: 16 / 9;
  margin: 2px 0; /* breathing room from the avatar row above / quote below, consistent with the card's own 9px flex gap already doing most of that */
}
.review-card__video--vertical {
  aspect-ratio: 9 / 16;
  /* Shorts cap — explicit requirement ("max-height 70svh on mobile,
     centered"); harmless/inert on desktop where the card's own fixed
     height already bounds things, kept unscoped since 70svh is itself
     a legitimate ceiling at any width, not just mobile. */
  max-height: 70svh;
  margin-left: auto;
  margin-right: auto;
}
.review-card__video-poster {
  position: absolute;
  inset: 0;
  background-size: cover;
  background-position: center;
  background-color: #1a1a1a; /* visible briefly if the thumbnail itself is still loading */
}
.review-card__video-badge {
  position: absolute;
  top: 10px;
  left: 10px;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 5px 10px;
  border-radius: 20px;
  background: rgba(6, 12, 12, 0.72);
  backdrop-filter: blur(4px);
  -webkit-backdrop-filter: blur(4px);
  color: #fff;
  font-family: var(--font-body);
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  pointer-events: none;
}
.review-card__video-play {
  position: absolute;
  z-index: 1;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  width: 64px;
  height: 64px;
  border-radius: 50%;
  border: none;
  background: var(--color-accent);
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
  transition: background 0.18s ease, transform 0.18s ease;
}
.review-card__video-play:hover { background: var(--color-accent-dark); transform: translate(-50%, -50%) scale(1.05); }
.review-card__video-play svg { margin-left: 3px; } /* optical centering — a triangle's own centroid sits left of its bounding box */
.review-card__video iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
}
/* Consent overlay — shown instead of loading the iframe when marketing
   consent isn't granted. Covers the whole video box; buttons stacked,
   full width, >=44px, explicit spec. */
.review-card__video-consent {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  justify-content: center;
  gap: 10px;
  padding: 20px;
  background: rgba(6, 12, 12, 0.88);
  box-sizing: border-box;
}
.review-card__video-consent-text {
  margin: 0 0 4px;
  color: #fff;
  font-family: var(--font-body);
  font-size: 14px;
  line-height: 1.4;
  text-align: center;
}
.review-card__video-consent-allow,
.review-card__video-consent-open {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: 0 16px;
  border-radius: 10px;
  font-family: var(--font-body);
  font-size: 14px;
  font-weight: 600;
  text-align: center;
  text-decoration: none;
  cursor: pointer;
  box-sizing: border-box;
}
.review-card__video-consent-allow {
  border: none;
  background: var(--color-accent);
  color: #fff;
}
.review-card__video-consent-allow:hover { background: var(--color-accent-dark); }
.review-card__video-consent-open {
  border: 1px solid rgba(255, 255, 255, 0.4);
  background: transparent;
  color: #fff;
}
.review-card__video-consent-open:hover { border-color: rgba(255, 255, 255, 0.7); }

/* Invisible spacers that push reviewer info to Figma y≈193 position */
.review-card__badge-space {
  flex-shrink: 0;
  height: 50px;
}
.review-card__photo-area {
  flex-shrink: 0;
  height: 104px;
}

/* Nav arrows */
.reviews__nav {
  padding: 0 200px;
  height: 63px;
  display: flex;
  align-items: center;
  gap: 8px;
}

.reviews__arrow {
  width: 32px;
  height: 32px;
  border: none;
  background: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  color: #353535;
  transition: opacity 0.2s;
}
.reviews__arrow:disabled { opacity: 0.25; cursor: default; pointer-events: none; }

/* CTA — same visual as .cases__cta but flow-centered in the flex-column stage */
.reviews__cta {
  align-self: center;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 245px;
  height: 70px;
  background: #ff2828;
  border-radius: 10px;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 12px;
  color: #fff;
  text-transform: uppercase;
  text-decoration: none;
  line-height: 1.03;
  transition: background 0.2s ease;
}
.reviews__cta:hover { background: var(--color-accent-dark); }
.reviews__arrow:not(:disabled):hover { opacity: 0.55; }

/* Mobile-only carousel dots, same pattern as .services__dots — hidden
   here on desktop (arrows are the desktop nav), shown + wired up only
   under the mobile breakpoint. */
.reviews__dots { display: none; }

/* ===================== FAQ ===================== */
.faq {
  overflow: hidden;
  position: relative;
}

.faq__stage {
  width: 1920px;
  transform-origin: top left;
  box-sizing: border-box;
  padding: 80px 0;
  display: flex;
  flex-direction: column;
  gap: 50px;
}

.faq__header {
  flex-shrink: 0;
  padding: 0 200px;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.faq__heading {
  font-family: var(--font-display);
  font-size: 55.54px;
  font-weight: 700;
  color: #353535;
  text-transform: uppercase;
  line-height: 1;
}
.faq__subhead {
  font-family: var(--font-body);
  font-size: 20px;
  font-weight: 600;
  color: var(--color-text-muted);
  line-height: 1.34;
}

/* List & items */
.faq__list {
  max-height: 787px;
  overflow-y: auto;
  padding: 0 200px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  scrollbar-width: thin;
  scrollbar-color: #d3d2d2 transparent;
}
.faq__list::-webkit-scrollbar { width: 3px; }
.faq__list::-webkit-scrollbar-track { background: transparent; border-radius: 10px; }
.faq__list::-webkit-scrollbar-thumb { background: #d3d2d2; border-radius: 10px; }

/* ---- Item shell ---- */
.faq-item {
  flex-shrink: 0; /* list scrolls rather than compressing expanded items */
  background: #d3d2d2;
  border-radius: 20px;
  overflow: hidden;
  transition: background 0.3s ease, opacity 0.3s ease;
}
.faq-item.open { background: #ececec; }
.faq-item.faq-item--dim-1 { opacity: 0.4; }
.faq-item.faq-item--dim-2 { opacity: 0.2; }

/* ---- COLLAPSED header (always visible) ----
   No explicit height any more (was a hardcoded 142px, .open switching it
   to height:auto) — that pairing is the classic can't-transition-to/
   from-auto case, and it was jumping instantly (measured live: 100.6px
   -> 80.75px in one frame, no transition) at the exact moment the body
   below started its own smooth 0.35s grid-rows transition, which is
   what actually read as "jerky": one instant snap + one smooth slide
   happening together doesn't look like a single coherent motion.
   height:auto in BOTH states instead, driven naturally by whichever
   child is tallest (the 64px divider — taller than the 2-line question
   — normally, or just the question once the divider/preview below have
   smoothly collapsed to 0 themselves): letting the browser compute it
   from now-transitioning children means the header's own height change
   is just a natural side effect of THEIR transitions, synced to the
   same 0.35s the body already uses, instead of a separate instant jump
   competing with it. Collapsed auto height lands within ~2px of the
   old hardcoded 142px (padding 76px + the 64px-tall divider), so this
   doesn't change the collapsed row's rhythm across items. */
.faq-item__header {
  display: flex;
  align-items: center;
  gap: 18px;
  padding: 38px 60px;
  box-sizing: border-box;
  cursor: pointer;
  user-select: none;
  transition: padding-bottom 0.35s ease;
}
/* Expanded header: top-align question with chevron, shrink bottom padding to 10px gap before h-divider */
.faq-item.open .faq-item__header {
  align-items: flex-start;
  padding-bottom: 10px;
}

/* Question column — Figma: 253px, text-21px Neutral Face Bold, line-height 1.34, tracking 1.05px */
.faq-item__question {
  flex: 0 0 253px;
  font-family: var(--font-display);
  font-size: 21px;
  font-weight: 700;
  color: #445156;
  text-transform: uppercase;
  letter-spacing: 1.05px;
  line-height: 1.34;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* Vertical divider — Figma: 4×64.215px, #B7B7B7 stroke, collapsed-state only.
   Was display:none on .open (instant, no transition) — one of the two
   children whose sudden disappearance made the header's own height jump
   in one frame (see .faq-item__header above). max-height+opacity here
   instead, same 0.35s as the body's own transition, so it shrinks away
   smoothly and the header's height follows it smoothly too. Stays
   display:block always now — an opacity transition can't run on an
   element that's display:none. */
.faq-item__v-divider {
  flex-shrink: 0;
  align-self: center;
  display: block;
  width: 4px;
  /* Real height PLUS max-height, not max-height alone: this element has
     no content of its own (just a background-image), so its intrinsic
     height is 0 — max-height only caps a height, it doesn't create one.
     max-height:64px on top of height:64px does nothing in the collapsed
     state (min(64,64)=64, unclipped) but is what actually transitions;
     .open below overrides only max-height, clipping the still-present
     64px down to 0 smoothly. Caught live: without the explicit height
     here this rendered at 0px even collapsed, not just on open. */
  height: 64px;
  max-height: 64px;
  opacity: 1;
  overflow: hidden;
  background: url('assets/icons/faq-divider-v.svg') no-repeat center / 100% 100%;
  transition: max-height 0.35s ease, opacity 0.2s ease;
}
.faq-item.open .faq-item__v-divider { max-height: 0; opacity: 0; }

/* Preview text — Figma: font-size 20px, line-height 0.97, color #5a686d,
   collapsed-state only. Same fix and same reason as .faq-item__v-divider
   above, minus the height issue (this one has real text content, so it
   already has an intrinsic height unlike the empty divider) — max-height
   44px comfortably clears its actual measured 2-line height (~42px; the
   38.8px estimate from 20px font * 0.97 line-height * 2 lines undershot
   the real rendered value slightly, and 40px was clipping ~2px off real
   content, caught live). Collapses to 0 smoothly instead of vanishing in
   one frame. */
.faq-item__preview {
  flex: 1;
  font-family: var(--font-body);
  font-size: 20px;
  font-weight: 600;
  color: var(--color-text-muted);
  line-height: 0.97;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
  max-height: 44px;
  opacity: 1;
  transition: max-height 0.35s ease, opacity 0.2s ease;
}
.faq-item.open .faq-item__preview { max-height: 0; opacity: 0; }

/* Chevron button — two distinct SVGs for down/up; bg color changes per state */
.faq-item__chevron {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}
.faq-item.open .faq-item__chevron { margin-left: auto; }
.faq-item__chevron img { display: block; }
/* specificity must beat .faq-item__chevron img (0,1,1) → use (0,2,0) */
.faq-item__chevron .faq-item__chevron-img--up { display: none; }
.faq-item.open .faq-item__chevron .faq-item__chevron-img--down { display: none; }
.faq-item.open .faq-item__chevron .faq-item__chevron-img--up { display: block; }

/* ---- EXPANDED body (grid 0fr → 1fr) ---- */
.faq-item__body {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.35s ease;
}
.faq-item.open .faq-item__body { grid-template-rows: 1fr; }

/* Direct grid child: overflow clips the inner content during collapse animation */
.faq-item__body-inner {
  overflow: hidden;
  min-height: 0;
}

/* Horizontal divider — Figma: 297px wide, thin #B7B7B7 line, 60px from left edge */
.faq-item__h-divider {
  height: 1px;
  width: 297px;
  margin-left: 60px;
  background: #b7b7b7;
}

/* Answer area — Figma: 10px top gap (question→h-divider already 10px), 60px sides, 38px bottom, gap 20px */
.faq-item__answer-area {
  padding: 10px 60px 38px;
  display: flex;
  flex-direction: column;
  gap: 20px;
}

/* Scroll wrap: positions the fog overlay */
.faq-item__answer-scroll-wrap {
  position: relative;
}

/* Scrollable answer text — 150px max, thin red scrollbar */
.faq-item__answer-scroll {
  max-height: 150px;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: #ff2828 transparent;
}
.faq-item__answer-scroll::-webkit-scrollbar { width: 3px; }
.faq-item__answer-scroll::-webkit-scrollbar-track { background: transparent; }
.faq-item__answer-scroll::-webkit-scrollbar-thumb { background: #ff2828; border-radius: 10px; }

/* Answer text — Figma: Mazzard M Regular, 20px, line-height 0.97, color #5a686d */
.faq-item__answer {
  font-family: var(--font-body);
  font-size: 20px;
  font-weight: 400;
  color: var(--color-text-muted);
  line-height: 0.97;
}

/* Scroll fog — bottom gradient, JS adds .scrolls to the wrap when content
   overflows. Was display:none -> block (instant pop, no transition) the
   moment .scrolls landed — checkOverflow() only runs 370ms after opening
   (has to: it needs the body's own 0.35s grid-rows transition to have
   actually finished before scrollHeight/clientHeight are trustworthy,
   measuring mid-transition would misfire on every item, truncated or
   not), so the accordion visually finished opening ~370ms in, then this
   just appeared out of nowhere with zero animation of its own — which is
   what actually read as "late and separate," not the 370ms wait itself.
   Can't safely start the fade any earlier for the reason above, but
   fading it in now (opacity, display:block always so the transition can
   run) instead of popping makes its arrival read as a quick settling
   flourish right as the accordion finishes, not a disconnected event. */
.faq-item__scroll-fog {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 60px;
  background: linear-gradient(to bottom, transparent, #ececec);
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.2s ease;
}
.faq-item__answer-scroll-wrap.scrolls .faq-item__scroll-fog { opacity: 1; }

/* "Читати повністю" — hidden by default, shown only when the answer
   above it is actually truncated (checkOverflow() in script.js adds
   .scrolls to .faq-item__answer-scroll-wrap exactly when
   scrollHeight > clientHeight). Was gated on .faq-item.open alone —
   showed the link for every open item regardless of whether the
   answer was cut off, including short answers with nothing more to
   read. .scrolls already only exists while the item is open (added
   after opening, removed on close — see openItem()/closeItem()), so
   gating on it alone is equivalent to the old .open check AND the
   missing truncation check in one. */
.faq-item__read-more {
  display: block;
  visibility: hidden;
  opacity: 0;
  pointer-events: none;
  align-self: flex-start;
  font-family: var(--font-body);
  font-size: 20px;
  font-weight: 600;
  color: #ff2828;
  text-decoration: none;
  line-height: 1;
  /* Same fade-in as .faq-item__scroll-fog just above, and for the same
     reason — this pops into visibility at the exact same moment (same
     .scrolls trigger), so leaving it an instant visibility flip while
     the fog now fades would have looked like only half the fix. */
  transition: opacity 0.2s ease;
}
.faq-item__answer-scroll-wrap.scrolls + .faq-item__read-more {
  visibility: visible;
  opacity: 1;
  pointer-events: auto;
}
.faq-item__read-more:hover { text-decoration: underline; }

/* Footer note + "Задати Питання" inline button — Figma: 23.704px */
.faq__footer-note {
  flex-shrink: 0;
  padding: 0 200px;
  /* margin-top: intentionally not set — flex gap:50px on .faq__stage handles equal spacing top and bottom */
  font-family: var(--font-body);
  font-size: 23.704px;
  font-weight: 600;
  color: var(--color-text-muted);
  line-height: 0.97;
}
.faq__ask-btn {
  background: none;
  border: none;
  padding: 0;
  font-family: var(--font-body);
  font-size: 24px;
  font-weight: 600;
  color: #ff2828;
  cursor: pointer;
  line-height: 1;
  text-decoration: none;
}
.faq__ask-btn:hover { text-decoration: underline; }

/* ===================== CTA BANNER ===================== */
.cta-wrap {
  overflow: hidden;
  position: relative;
}

.cta-wrap__stage {
  width: 1920px;
  transform-origin: top left;
  box-sizing: border-box;
  padding: 120px 0 0;
}

.cta-banner {
  max-width: 1241px;
  margin: 0 auto;
  border-radius: 40px;
  background: var(--color-bg-dark) url(assets/images/cta-portrait.webp) right center / cover no-repeat;
  min-height: 467px;
  display: flex;
  align-items: center;
  overflow: hidden;
  padding: 70px 0 70px 120px;
}

.cta-banner__content {
  display: flex;
  flex-direction: column;
  gap: 30px;
  max-width: 502px;
  position: relative;
  z-index: 2;
}

.cta-banner__header {
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.cta-banner__star {
  font-family: var(--font-display);
  font-size: 35.73px;
  font-weight: 700;
  color: #ff2828;
  line-height: 1.03;
  text-transform: uppercase;
}

.cta-banner__heading {
  font-family: var(--font-display);
  font-size: 58px;
  font-weight: 700;
  color: #fff;
  text-transform: uppercase;
  line-height: 0.95;
  max-width: 502px;
  /* Hard guarantee against clipping, base-rule scope so it applies
     regardless of which breakpoint's font-size wins the cascade —
     same reasoning as the modal heading fix earlier in this
     engagement. Not the primary fix for the mobile overflow this
     caught (that's the font-size drop below), just the backstop. */
  overflow-wrap: break-word;
}

.cta-banner__sub {
  font-family: var(--font-body);
  font-size: 20px;
  font-weight: 500;
  color: var(--color-text-muted-dark);
  letter-spacing: 1px;
  max-width: 420px;
  line-height: 1.0;
}

.cta-banner__btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 245px;
  height: 70px;
  padding: 0 29px;
  background: #ff2828;
  border: none;
  border-radius: 10px;
  font-family: var(--font-display);
  font-size: 12px;
  font-weight: 700;
  color: #fff;
  text-transform: uppercase;
  text-decoration: none;
  letter-spacing: 0;
  cursor: pointer;
  align-self: flex-start;
  transition: background 0.2s;
}
.cta-banner__btn:hover { background: #e01f1f; }

/* ===================== FOOTER ===================== */
.footer {
  background: var(--color-bg-light);
  padding: 120px 20px 120px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 14px;
}

/* Glassmorphic pill */
.footer__pill {
  width: 100%;
  max-width: 880px;
  background: var(--bg-footer);
  backdrop-filter: blur(var(--blur-footer));
  -webkit-backdrop-filter: blur(var(--blur-footer));
  border-radius: var(--radius-footer);
  padding: 30px 74px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 30px;
}

/* Left: wordmark + copyright stacked */
.footer__brand {
  flex-shrink: 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
}

.footer__wordmark {
  font-family: var(--font-display);
  font-size: 24.637px;
  font-weight: 400;
  color: #606060;
  text-transform: uppercase;
  line-height: 1.03;
}

.footer__copyright {
  font-size: 14px;
  font-weight: 600;
  color: var(--color-text-faint);
  line-height: 1.32;
}

/* Right: social icons + tagline stacked */
.footer__right {
  flex-shrink: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 10px;
}

.footer__social {
  display: flex;
  align-items: center;
  gap: 20px;
}

.footer__social-link {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 27px;
  height: 27px;
  color: #5a5a5a;
  text-decoration: none;
  transition: color 0.2s;
}
.footer__social-link:hover { color: #FF2828; }
.footer__social-link:focus,
.footer__social-link:active { color: #5a5a5a; }
.footer__social-link svg { display: block; }

.footer__tagline {
  font-size: 14px;
  font-weight: 600;
  color: var(--color-text-faint);
  line-height: 1.32;
}

/* Legal links below the pill */
.footer__nav {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0 24px;
  padding: 0 20px;
}

/* 2026-10-15: demoted to a secondary/legal-link treatment — smaller,
   lighter weight, no color change or accent on interaction, underline
   as the only affordance (hover AND :focus-visible, so keyboard focus
   gets the same cue a mouse hover does). --color-text-faint is
   already the correct muted token for this light background (verified
   ≥4.5:1 in the 2026-10-14 contrast pass) — kept as-is, not swapped
   for a different token. */
.footer__nav a {
  font-size: 14px;
  font-weight: 400;
  color: var(--color-text-faint);
  text-decoration: none;
  transition: color 0.2s;
}
.footer__nav a:hover,
.footer__nav a:focus-visible { text-decoration: underline; }

/* ===================== MODAL ===================== */
.modal-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(6, 12, 12, 0.72);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 2000;
  padding: 20px;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.25s ease;
}
.modal-backdrop.is-open {
  opacity: 1;
  pointer-events: auto;
}

.modal {
  position: relative;
  width: 755px;
  max-width: 100%;
  min-height: 559px;
  border-radius: 40px;
  background: rgba(184, 184, 184, 0.4);
  backdrop-filter: blur(50px);
  -webkit-backdrop-filter: blur(50px);
  padding: 56px 55px;
  max-height: calc(100vh - 40px);
  overflow-y: auto;
  box-sizing: border-box;
}

/* Close — red circle with white X, top-right */
.modal__close {
  position: absolute;
  top: 18px;
  right: 18px;
  width: 26px;
  height: 26px;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #ff2828;
  border: none;
  border-radius: 50%;
  cursor: pointer;
  transition: background 0.2s;
}
.modal__close:hover { background: #e01f1f; }

.modal__content { display: flex; flex-direction: column; }

/* Star ✦ stacked above heading with 10px gap (matches Figma group 1:2629) */
.modal__header {
  display: flex;
  flex-direction: column;
  gap: 10px;
  /* 2026-10-15 bug report #3: title→subtitle gap spec'd at 16px (was
     24px) — this margin-bottom is that gap, since .modal__heading is
     .modal__header's last child and .modal__sub is its very next
     sibling. */
  margin-bottom: 16px;
}
.modal__star {
  font-family: var(--font-display);
  font-size: 35.735px;
  font-weight: 700;
  color: #ff2828;
  line-height: 1.03;
}
.modal__heading {
  font-family: var(--font-display);
  font-size: 58px;
  font-weight: 700;
  color: #fff;
  text-transform: uppercase;
  /* 2026-10-15 bug report #3: was 0.95 — lines of a 2-line heading
     (e.g. "ГОТОВІ<br>ЗАТЯГНУТИСЬ?") touched each other at that ratio;
     1.1 gives them real breathing room. */
  line-height: 1.1;
  /* Safety net at every width, not just mobile — a single long
     unbreakable word (e.g. "ЗАТЯГНУТИСЬ?") has nowhere to wrap by
     default and just overflows its box instead; this lets it break
     rather than clip, whatever the container width turns out to be. */
  overflow-wrap: break-word;
}

.modal__sub {
  font-family: var(--font-body);
  font-size: 20px;
  font-weight: 500;
  color: #d6d6d6;
  letter-spacing: 1px;
  /* 2026-10-15 bug report #3: was 1.0 (and 30px margin-bottom below) —
     line-height 1.45 and a 24px gap to the first form field. */
  line-height: 1.45;
  margin-bottom: 24px;
}

/* Quiz-only banner — injected by window.WSCtaModal.open({quiz:...})
   (script.js), removed on close. Sits between .modal__sub and the
   fields; dark-glass theme matches the rest of this modal (unlike the
   quiz's OWN screen, which is monochrome — this is still the site's
   normal dark modal, just carrying quiz-specific copy). */
.modal__quiz-banner {
  margin-bottom: 22px;
  padding: 16px 18px;
  border-left: 2px solid #ff2828;
  background: rgba(255, 255, 255, 0.05);
}
.modal__quiz-quote {
  margin: 0 0 8px;
  padding: 0;
  font-family: var(--font-body);
  font-size: 14px;
  font-style: italic;
  color: #b7b7b7;
  line-height: 1.4;
}
.modal__quiz-message {
  margin: 0;
  font-family: var(--font-body);
  font-size: 15px;
  font-weight: 600;
  color: #fff;
  line-height: 1.4;
}

.modal__form { display: flex; flex-direction: column; }

/* Honeypot field (2026-10-16, pre-handoff audit) — a real text input a
   bot's form-filler will happily fill in, but no human ever sees or
   reaches: off-screen (not display:none/visibility:hidden, which some
   bots skip filling), tabindex=-1 so Tab never lands on it, aria-
   hidden + a non-label name so screen readers never announce it.
   Shared by every lead form (modal.js/ask-modal.js/lead-modals.js) —
   see each form's own submit handler in script.js for the check. */
.modal__hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

/* Two inputs side-by-side (Figma: each 245×58px, rgba(255,255,255,0.45), 0.5px solid white, r10, p29) */
.modal__fields {
  display: flex;
  flex-direction: row;
  gap: 16px;
  margin-bottom: 14px;
}
/* Was height:58px / font-size:14px — under the 16px floor iOS Safari
   uses to decide whether to auto-zoom an input on focus (below that,
   it zooms the whole page in and the user has to manually zoom back
   out after). 16px clears it; height bumped to match the larger type
   rather than leaving it visually cramped against the new font-size. */
.modal__input {
  flex: 1;
  height: 62px;
  background: rgba(255, 255, 255, 0.45);
  border: 0.5px solid #fff;
  border-radius: 10px;
  padding: 0 29px;
  font-family: var(--font-body);
  font-size: 16px;
  font-weight: 500;
  color: #fff;
  outline: none;
  transition: border-color 0.2s;
}
.modal__input::placeholder { color: #d9d9d9; }
.modal__input:focus { border-color: rgba(255, 255, 255, 0.85); }

/* ---------- Phone field (intl-tel-input, cdnjs v19.5.7) ----------
   The library wraps the actual <input> (still .modal__input — all the
   rules above keep applying to it directly, only the padding-left
   below needs to account for the dial-code trigger now sitting inside
   that padding) in a .iti div and wants to own that wrapper's sizing.
   .iti takes over the flex-item role .modal__input itself used to
   play directly inside .modal__fields (flex:1, matching the name
   field next to it); the input keeps stretching to fill the wrapper
   via height/width:100%. Visual theme (colors, radius, borders) is
   matched to .modal__input's own glassmorphic look rather than the
   library's default plain-white styling; dropdown panel matches
   .navbar__lang-menu's own dark/blurred panel treatment elsewhere on
   this site, for consistency rather than inventing a new one. */
.modal__fields .iti {
  flex: 1;
  display: block;
}
/* No explicit height here — .modal__input's own height:62px (above)
   already applies directly to this same <input> (the library wraps it
   without removing its class), and .iti being a plain block wrapping
   one child naturally sizes to that. Giving the input height:100% here
   too would create a circular reference instead (.iti has no height
   of its own to resolve 100% against except via its child) and the
   percentage would just resolve to auto, silently dropping the
   62px. */
.iti input.modal__input {
  width: 100%;
}
/* Код країни стоїть поруч із прапором (showSelectedDialCode, script.js),
   тож ширина тригера залежить від коду (+1 vs +380): відступ поля
   бібліотека виставляє сама inline-стилем за реальною шириною тригера
   (+6px). text-indent додає проміжок між рискою-розділювачем і першою
   цифрою (і плейсхолдером), не воюючи з тим inline-відступом. */
.iti--show-selected-dial-code input.modal__input { text-indent: 10px; }
/* Номер набрано на всю довжину маски, але такого номера не існує (00000…). */
.iti input.modal__input.is-invalid { border-color: #ff2828; }
.iti__flag-container {
  border-radius: 10px 0 0 10px;
}
.iti__selected-flag {
  height: 100%;
  padding: 0 10px 0 16px;
  border-radius: 10px 0 0 10px;
  background: rgba(255, 255, 255, 0.12);
  /* Minimal separation from the number — same hairline used for
     .vmodal__divider elsewhere on the site, so the trigger reads as a
     distinct control instead of running into the digits. */
  border-right: 1px solid rgba(255, 255, 255, 0.18);
}
.iti__selected-flag:hover,
.iti__selected-flag:focus { background: rgba(255, 255, 255, 0.22); }
.iti__selected-dial-code {
  color: #fff;
  font-family: var(--font-body);
  font-weight: 500;
}
.iti__arrow { border-top-color: #fff; }
.iti__arrow--up { border-bottom-color: #fff; }

/* Country dropdown panel — appended to <body> (dropdownContainer,
   script.js), so it needs its own complete visual treatment, not just
   an override of something already inside .modal__input's own glass
   card. z-index above .modal-backdrop's own 2000 — the whole reason
   for appending to <body> in the first place is so the list renders
   above the modal instead of being clipped by its overflow-y:auto,
   and a z-index under the modal's own would silently undo that even
   though the DOM placement is already correct. */
.iti--container {
  z-index: 2100;
}
.iti__dropdown-content {
  background: rgba(10, 10, 10, 0.92);
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 10px;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
  overflow: hidden;
}
.iti__search-input {
  background: rgba(255, 255, 255, 0.08);
  color: #fff;
  font-family: var(--font-body);
  font-size: 16px; /* same iOS-auto-zoom floor as .modal__input */
  margin: 8px;
  width: calc(100% - 16px);
}
.iti__search-input::placeholder { color: rgba(255, 255, 255, 0.5); }
.iti__country-list {
  background: transparent;
  max-height: 220px;
}
.iti__country {
  color: #fff;
  font-family: var(--font-body);
  padding: 10px 12px;
}
.iti__country.iti__highlight,
.iti__country:hover { background: rgba(255, 40, 40, 0.15); }
.iti__dial-code { color: rgba(255, 255, 255, 0.55); }
.iti__divider { border-bottom: 1px solid rgba(255, 255, 255, 0.12); }
.iti__flag-box { margin-right: 8px; }

/* Textarea — used in the "Задати Питання" modal */
.modal__textarea {
  height: 100px;
  padding: 14px 29px;
  resize: none;
  line-height: 1.5;
  width: 100%;
  flex: none;
  margin-bottom: 14px;
}

/* Bottom row: submit (245×58) on the left, checkboxes on the right — same horizontal line */
.modal__bottom-row {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 24px;
}
.modal__submit {
  width: 245px;
  height: 58px;
  flex-shrink: 0;
  border: none;
  border-radius: 10px;
  background: rgba(255, 255, 255, 0.28);
  font-family: var(--font-display);
  /* Was 12px (measured ~11.7px rendered) in every state — disabled,
     enabled, loading — on every modal site-wide (CTA/ask/design/dev/
     traffic lead forms). Below the 14px floor used everywhere else;
     box is fixed-size (58px tall) with plenty of headroom for 2px more. */
  font-size: 14px;
  font-weight: 700;
  color: rgba(255, 255, 255, 0.45);
  text-transform: uppercase;
  letter-spacing: 0;
  cursor: not-allowed;
  transition: background 0.2s, color 0.2s;
}
.modal__submit:not([disabled]) {
  background: #ff2828;
  color: #fff;
  cursor: pointer;
}
.modal__submit:not([disabled]):hover { background: #e01f1f; }

/* Loading state — quiz-sourced submits only (script.js's initCtaModal,
   see the [disabled] rule above for the plain not-yet-valid state this
   sits on top of). Text swap only, spinner via a CSS-only rotating
   pseudo-element (transform, no JS animation loop) — same pattern the
   quiz's own screen previously used for this. */
.modal__submit.is-loading {
  color: transparent;
  position: relative;
  cursor: default;
}
.modal__submit.is-loading::after {
  content: '';
  position: absolute;
  left: 50%;
  top: 50%;
  width: 18px;
  height: 18px;
  margin: -9px 0 0 -9px;
  border: 2px solid rgba(255, 255, 255, 0.4);
  border-top-color: #fff;
  border-radius: 50%;
  animation: modalSubmitSpin 0.7s linear infinite;
}
@keyframes modalSubmitSpin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .modal__submit.is-loading::after { animation: none !important; }
}

/* Only ever shown for a quiz-sourced submit that hit a real
   submitEndpoint and got back a non-2xx/network error. */
.modal__submit-error {
  margin: 10px 0 0;
  font-family: var(--font-body);
  font-size: 13px;
  color: #ff6b6b;
  text-align: center;
}

/* Channels: horizontal flex row */
.modal__channels {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: 18px;
  flex-wrap: wrap;
}
.modal__channel {
  display: flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
}
.modal__channel input[type="checkbox"] {
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
}

/* Outer box: 18×18, radius 5, bg #acacac, border 0.5px #d1d1d1 */
.modal__check-box {
  position: relative;
  width: 18px;
  height: 18px;
  border-radius: 5px;
  background: #acacac;
  border: 0.5px solid #d1d1d1;
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: background 0.15s, border-color 0.15s;
}
/* Inner fill: 10×10, radius 3, bg #d3d3d3 (unchecked) */
.modal__check-box::after {
  content: '';
  width: 10px;
  height: 10px;
  border-radius: 3px;
  background: #d3d3d3;
  transition: background 0.15s;
}
/* Checked: outer → #dcb4b4/#caa9a9, inner → #ff2828 */
.modal__channel input:checked + .modal__check-box {
  background: #dcb4b4;
  border-color: #caa9a9;
}
.modal__channel input:checked + .modal__check-box::after {
  background: #ff2828;
}

.modal__check-label {
  font-family: var(--font-body);
  font-size: 12px;
  font-weight: 600;
  color: #f7f7f7;
  white-space: nowrap;
  user-select: none;
}

/* Success state */
.modal__success {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  min-height: 447px;
  gap: 20px;
}
.modal__success-heading {
  font-family: var(--font-display);
  font-size: 58px;
  font-weight: 700;
  color: #fff;
  text-transform: uppercase;
  line-height: 0.95;
  overflow-wrap: break-word;
}
.modal__success-text {
  font-family: var(--font-body);
  font-size: 20px;
  font-weight: 500;
  color: #d6d6d6;
  letter-spacing: 1px;
  line-height: 1.4;
}

/* Success icon — shared by all 4 success states (CTA + design/dev/traffic
   lead modals). `pathLength="1"` on the SVG shapes (set in markup) lets
   the stroke-dash values below be unitless fractions instead of fragile
   pixel-length guesses. Resting state (no JS class, or reduced-motion) is
   just the fully-drawn icon at rest — no animation dependency for a
   correct final look. initXModal()'s playSuccessAnimation() in script.js
   toggles .is-animating (with a reflow in between) to (re)play the intro
   on every submit, including repeat submissions in the same session. */
.modal__success-icon {
  width: 64px;
  height: 64px;
}
.modal__success-icon-check {
  stroke-dasharray: 1;
  stroke-dashoffset: 0;
}

@media (prefers-reduced-motion: no-preference) {
  .modal__success.is-animating .modal__success-icon {
    animation: modalSuccessPop 320ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
  }
  .modal__success.is-animating .modal__success-icon-check {
    stroke-dashoffset: 1;
    animation: modalSuccessDraw 260ms ease-out 200ms both;
  }
  .modal__success.is-animating .modal__success-heading {
    animation: modalSuccessFadeUp 280ms ease-out 320ms both;
  }
  .modal__success.is-animating .modal__success-text {
    animation: modalSuccessFadeUp 280ms ease-out 400ms both;
  }
}

@keyframes modalSuccessPop {
  0%   { transform: scale(0.5); opacity: 0; }
  60%  { transform: scale(1.08); opacity: 1; }
  100% { transform: scale(1); opacity: 1; }
}
@keyframes modalSuccessDraw {
  to { stroke-dashoffset: 0; }
}
@keyframes modalSuccessFadeUp {
  from { opacity: 0; transform: translateY(10px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* Auto-close progress bar — QUIZ-ORIGIN success only (initCtaModal()
   in script.js shows/starts this only when the modal was opened via
   window.WSCtaModal.open({quiz:...}); the normal header/banner path
   never un-hides it, so its success view is completely unchanged).
   Positioned absolute against .modal itself (the nearest `position:
   relative` ancestor, a sibling of #ctaModalSuccess, not a child of
   it) so bottom:0/left:0/right:0 spans the card's own full width at
   its padding-box edge — border-radius matches .modal's own 40px on
   the bottom corners + overflow:hidden so the red fill (which starts
   at 0% and grows) never pokes a square corner out past the card's
   rounded ones. The fill itself is a plain width 0%→100% animation,
   not a JS-driven rAF loop — 3000ms is the single source of truth
   also used by the real auto-close setTimeout in script.js, so the
   bar reaching full width and the modal actually closing land on the
   same instant by construction, not by tuning two numbers to match. */
.modal__success-progress {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 3px;
  background: rgba(255, 255, 255, 0.15);
  border-radius: 0 0 40px 40px;
  overflow: hidden;
}
.modal__success-progress-bar {
  height: 100%;
  width: 0%;
  background: #ff2828;
}
@media (prefers-reduced-motion: no-preference) {
  .modal__success-progress.is-filling .modal__success-progress-bar {
    animation: modalSuccessProgressFill 3000ms linear forwards;
  }
}
@keyframes modalSuccessProgressFill {
  from { width: 0%; }
  to   { width: 100%; }
}

/* =====================================================================
   VIDEO REVIEW MODAL (vmodal)  —  clean rebuild, two variants
   Variant A (.vmodal--horizontal): 880×816px, radius 8px, video 830×416 top, info below
   Variant B (.vmodal--portrait):   839×683px, radius 40px, info left, video 361×558 right
   Both: rgba(184,184,184,0.4) + backdrop-filter blur(50px) per Figma 1:2334 / 1:2312
===================================================================== */

/* ---------- Backdrop — matches .modal-backdrop: same darkness, same pointer-events pattern ---------- */
.vmodal-backdrop {
  position: fixed;
  inset: 0;
  background: rgba(6, 12, 12, 0.72);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  z-index: 1100;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.25s ease;
}
.vmodal-backdrop.is-open {
  opacity: 1;
  pointer-events: auto;
}

/* ---------- Modal card base ---------- */
.vmodal {
  position: relative;
  background: rgba(184, 184, 184, 0.4);
  backdrop-filter: blur(100px);
  -webkit-backdrop-filter: blur(100px);
  overflow: hidden;
  display: flex;
  box-sizing: border-box;
  max-width: calc(100vw - 32px);
  max-height: 90vh;
  transform: translateY(16px);
  transition: transform 0.25s ease;
}
.vmodal-backdrop.is-open .vmodal { transform: translateY(0); }

/* ---------- Close button: 17px red circle, absolute ---------- */
.vmodal__close {
  position: absolute;
  z-index: 10;
  width: 17px;
  height: 17px;
  border-radius: 50%;
  background: #ff2828;
  border: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  transition: background 0.2s;
}
.vmodal__close::before { content: ''; position: absolute; inset: -10px; }
.vmodal__close:hover { background: #e01f1f; }

/* ---------- Video area ---------- */
.vmodal__video-area {
  position: relative;          /* anchor for absolute-positioned injected video/iframe */
  background: rgba(250, 250, 250, 0.35);
  flex-shrink: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}

/* ---------- Play button: 82px red circle ---------- */
.vmodal__play-btn {
  width: 82px;
  height: 82px;
  border-radius: 50%;
  background: #ff2828;
  border: none;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  padding-left: 5px;
  transition: background 0.2s, transform 0.2s;
}
.vmodal__play-btn:hover { background: #e01f1f; transform: scale(1.06); }

/* ---------- Info panel ---------- */
.vmodal__info {
  display: flex;
  flex-direction: column;
  min-height: 0;
}

/* Reviewer row: avatar + name/role */
.vmodal__reviewer {
  flex-shrink: 0;
  display: flex;
  align-items: center;
  gap: 12px;
}
.vmodal__avatar {
  flex-shrink: 0;
  width: 63.18px;
  height: 63.18px;
  border-radius: 50%;
  background: #C2C2C2;
  border: 1.24px solid #9B9B9B;
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
}
.vmodal__initials {
  font-family: var(--font-display);
  font-size: 19.8px;
  font-weight: 700;
  color: #373737;
  text-transform: uppercase;
  line-height: 1;
}
.vmodal__meta { flex: 1; min-width: 0; }
/* Text colors below were dark (#000/#3a3a3a/#2d2d2d) on the exact same
   translucent rgba(184,184,184,0.4) frosted-glass background the
   lead-form .modal uses WHITE text on (.modal__heading #fff,
   .modal__sub #d6d6d6) — same background formula, opposite (wrong)
   text-color choice, reading as grey-on-grey. Matched to .modal's own
   values instead of picking new ones, since that pairing is already
   proven legible against this exact background elsewhere on the site.
   Applies at every width (not mobile-only) — this is the same bug on
   desktop too, just not surfaced until mobile testing this round. */
.vmodal__name {
  font-family: var(--font-display);
  font-size: 24.5px;
  font-weight: 700;
  color: #fff;
  text-transform: uppercase;
  line-height: 1.32;
  letter-spacing: 0;
}
.vmodal__role {
  font-family: var(--font-body);
  font-size: 14.6px;
  font-weight: 600;
  color: #d6d6d6;
  margin-top: 4px;
  line-height: 1;
}

/* Horizontal divider — was rgba(0,0,0,0.18), a dark line tuned for the
   old dark-text theme; inverted to a light line for the new white-text
   one so it stays visible instead of disappearing into the backdrop. */
.vmodal__divider {
  flex-shrink: 0;
  height: 1px;
  background: rgba(255, 255, 255, 0.18);
}

/* Quote area: flex row — red accent bar + scrollable text */
.vmodal__quote-wrap {
  flex: 1;
  min-height: 0;
  display: flex;
  gap: 14px;
  overflow: hidden;
}
/* 3px red accent bar — stretches to match quote-scroll height */
.vmodal__quote-bar {
  flex-shrink: 0;
  width: 3px;
  border-radius: 10px;
  background: #ff2828;
}
.vmodal__quote-scroll {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: #ff2828 rgba(255, 255, 255, 0.15);
}
.vmodal__quote-scroll::-webkit-scrollbar { width: 3px; }
.vmodal__quote-scroll::-webkit-scrollbar-track { background: rgba(255, 255, 255, 0.15); border-radius: 10px; }
.vmodal__quote-scroll::-webkit-scrollbar-thumb { background: #ff2828; border-radius: 10px; }
.vmodal__quote {
  font-family: var(--font-body);
  font-size: 19.7px;
  font-weight: 400;
  color: #fff;
  line-height: 1;
  letter-spacing: 0;
}

/* "Переглянути кейс" link */
.vmodal__case-link {
  flex-shrink: 0;
  font-family: var(--font-body);
  font-size: 21px;
  font-weight: 600;
  color: #ff2828;
  text-decoration: none;
  transition: opacity 0.2s;
}
.vmodal__case-link:hover { opacity: 0.75; }

/* ================================================================
   VARIANT A — HORIZONTAL (data-orientation="horizontal" or default)
   880×816px · radius 8px · video 830×416 on top · info flex-column below
   Source: Figma 1:2334
================================================================ */
.vmodal--horizontal {
  width: 880px;
  height: 816px;
  border-radius: 8px;
  flex-direction: column;
}
.vmodal--horizontal .vmodal__close { top: 12px; right: 12px; }
.vmodal--horizontal .vmodal__video-area {
  width: 830px;
  height: 416px;
  border-radius: 10px;
  margin: 27px auto 0;
}
.vmodal--horizontal .vmodal__info {
  flex: 1;
  padding: 18px 62px 36px 62px;
  gap: 14px;
}
/* Horizontal variant: no red accent bar on quote */
.vmodal--horizontal .vmodal__quote-bar { display: none; }
/* Horizontal variant: add arrow to link */
.vmodal--horizontal .vmodal__case-link::after { content: ' →'; }

/* ================================================================
   VARIANT B — PORTRAIT (data-orientation="portrait")
   839×683px · radius 40px · info LEFT · video RIGHT
   Video: 361×558px · radius 20px · margin top 60px, right 43px, bottom 65px
   Source: Figma 1:2312
================================================================ */
.vmodal--portrait {
  width: 839px;
  height: 683px;
  border-radius: 40px;
  flex-direction: row-reverse;
}
.vmodal--portrait .vmodal__close { top: 26px; right: 26px; }
.vmodal--portrait .vmodal__video-area {
  width: 361px;
  height: 558px;
  border-radius: 20px;
  margin: 60px 43px 65px 0;
  flex-shrink: 0;
}
.vmodal--portrait .vmodal__info {
  flex: 1;
  padding: 70px 50px 70px 58px;
  gap: 18px;
  min-width: 0;
}
/* Portrait: quote box is fixed 380px tall, scrolls internally — same thin red scrollbar as FAQ answers */
.vmodal--portrait .vmodal__quote-wrap {
  flex: none;
  height: 380px;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: #ff2828 transparent;
}
.vmodal--portrait .vmodal__quote-wrap::-webkit-scrollbar { width: 3px; }
.vmodal--portrait .vmodal__quote-wrap::-webkit-scrollbar-track { background: transparent; }
.vmodal--portrait .vmodal__quote-wrap::-webkit-scrollbar-thumb { background: #ff2828; border-radius: 10px; }

/* ===================== RESPONSIVE — TABLET ===================== */
@media (max-width: 1200px) {
  :root { --pad-x: 40px; }

  .hero__info { max-width: 200px; }

  .footer__pill { padding: 26px 40px; }

  .modal { padding: 48px 40px; min-height: auto; }
  .modal__heading { font-size: 44px; }
  .modal__bottom-row { gap: 16px; }
  .modal__submit { width: 200px; }
}

/* ===================== RESPONSIVE — MOBILE ===================== */
@media (max-width: 768px) {
  :root { --pad-x: 20px; }

  /* NAVBAR — values pulled from Figma "iPhone 16 - 5" header group
     (node 421:123, get_design_context + get_screenshot), not eyeballed:
     compact 354×60 glass pill, top:66/left:20/right:19 within the 393px
     frame, rounded-[15px], bg rgba(184,184,184,0.1). Was inheriting the
     desktop pill (top:23, height:70, radius:80 = full stadium, bg
     rgba(255,255,255,0.08)) unmodified. */
  .navbar {
    top: 66px;
    left: 20px;
    right: 19px;
    height: 60px;
    padding: 0 6px 0 16px;
    border-radius: 15px;
    background: rgba(184, 184, 184, 0.1);
  }
  /* Scrolled state sits closer to the top edge — 66px was the hero's own
     resting position (tuned against Figma's frame, see above), not a
     value that should hold once the page is actually scrolled and the
     hero context is gone; leaving it fixed there the whole time is what
     read as "too low," catching the eye and overlapping section
     headings as they scroll past. env(safe-area-inset-top) (0 on
     devices/contexts without one, e.g. most Android) keeps this clear of
     a real notch/status bar instead of a flat guess baked in for every
     device — +6px is breathing room beyond that inset, not the inset
     itself, so the pill doesn't sit flush against the very edge; the
     10px floor covers non-notch phones where the calc alone would sit
     too close to the true top. .is-scrolled toggles at just 50px of
     scroll (script.js) — deliberately early, so this engages almost
     immediately rather than only near the very top of a long scroll. */
  .navbar.is-scrolled {
    top: max(10px, calc(env(safe-area-inset-top, 0px) + 6px));
  }
  /* Two states, swapped by .navbar.is-nav-open (initMobileNavToggle in
     script.js), both living in the same grid cell so toggling between
     them is a pure cross-fade — no layout shift, nothing reflows.
     CLOSED (default): .navbar__closed-controls (lang/phone/lead-request)
     shown, .navbar__nav hidden.
     OPEN: reversed — the nav list (same <ul> desktop uses, no separate
     mobile markup) takes over, horizontally-scrollable since 5 items
     don't all fit the compact pill width. Dragging it sideways is
     handled by initNavScroll() below, reusing the cases-drum drag-vs-
     click state machine so a swipe never fires a link. */
  .navbar { gap: 8px; }
  .navbar__middle {
    display: grid;
    flex: 1 1 auto;
    min-width: 0;
    height: 100%;
    position: relative;
  }
  .navbar__nav,
  .navbar__closed-controls {
    grid-area: 1 / 1;
    min-width: 0;
    transition: opacity 0.2s ease, visibility 0.2s ease;
  }
  /* touch-action was pan-y — vertical native, HORIZONTAL handled by a
     hand-rolled pointermove→scrollLeft drag in initNavScroll()
     (script.js), the same drag-vs-click machine .cases-drum's own
     canvas needs (it has no native scrolling at all to fall back on).
     Copied here even though this row is a plain <ul><li><a>, which
     never needed that: real mobile browsers already tell a scroll-drag
     across a link apart from a tap on their own, so overflow-x:auto
     alone was always enough — the JS layer was only ever fighting the
     platform's own momentum/inertia, which is exactly what read as
     "hard to move" on a real phone. pan-x now hands horizontal panning
     to the browser outright (vertical still passes through to the page,
     same as before) — see initNavScroll for the matching JS removal. */
  .navbar__nav {
    display: flex;
    align-items: stretch;
    height: 100%;
    gap: 4px;
    overflow-x: auto;
    overflow-y: hidden;
    scrollbar-width: none;
    -ms-overflow-style: none;
    touch-action: pan-x;
    -webkit-overflow-scrolling: touch;
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
  }
  .navbar__nav::-webkit-scrollbar { display: none; }
  .navbar__nav li { display: flex; align-items: center; flex-shrink: 0; }
  .navbar__nav a {
    display: flex;
    align-items: center;
    height: 44px;
    padding: 0 10px;
    white-space: nowrap;
  }

  /* Scroll affordance — a subtle edge fade hinting more nav items sit
     off-screen, same idea as .faq-item__scroll-fog / .faq-main__list-fog
     elsewhere: JS (initNavScroll in script.js) toggles .is-visible per
     side based on actual scroll position, so it only shows on the side
     that still has content to reveal and never on the side already at
     its end. Layered above the nav row itself (z-index), below the
     dropdown/menu chrome; pointer-events:none so it never blocks the
     drag/tap it's just hinting at.
     Narrow (14px — the outermost sliver, not a band across items) and
     low-peak (0.2 alpha, down from an earlier 0.55 that read as a
     visible dark rectangle with a hard inner edge and dimmed the text
     under it). A 3-stop curve — sharper drop-off in the first half,
     gentler in the second — rather than a flat linear fade, so there's
     no perceptible line where it "ends"; it just tapers out. */
  .navbar__nav-fog {
    position: absolute;
    top: 0;
    bottom: 0;
    width: 14px;
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.2s ease;
    z-index: 2;
  }
  .navbar__nav-fog--left {
    left: 0;
    background: linear-gradient(to right,
      rgba(6, 12, 12, 0.20) 0%,
      rgba(6, 12, 12, 0.08) 55%,
      rgba(6, 12, 12, 0) 100%);
  }
  .navbar__nav-fog--right {
    right: 0;
    background: linear-gradient(to left,
      rgba(6, 12, 12, 0.20) 0%,
      rgba(6, 12, 12, 0.08) 55%,
      rgba(6, 12, 12, 0) 100%);
  }
  .navbar.is-nav-open .navbar__nav-fog.is-visible {
    opacity: 1;
  }

  .navbar__closed-controls {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    width: 100%;
    gap: 8px;
    margin-left: 0;
    opacity: 1;
    visibility: visible;
  }
  /* Fixed sizing on all three — at 320px the pill has just enough room
     for lang+phone+lead at their real 44px-tall size; without this a
     flex row that's a few px short shrinks the last child instead of
     overflowing, which is how .navbar__lead was measured collapsing to
     ~34px tall there before this. */
  .navbar__closed-controls > * { flex-shrink: 0; }
  .navbar.is-nav-open .navbar__closed-controls {
    pointer-events: none;
  }
  /* Phone + lead now slide+fade in the same direction and on the same
     0.4s/cubic-bezier(0.76,0,0.24,1) timing as the lang widget below
     (matches the preloader's own easing, same as that widget) — was a
     plain 0.2s opacity-only fade, which read as "not animating" next
     to the widget's visibly slower slide; same clock, same motion,
     the whole closed-controls group now reads as one sweep instead of
     two different speeds. Kept as a separate rule from the parent
     (.navbar__closed-controls) rather than folded into it: opacity on
     the PARENT would flatten each child's own opacity to 0 immediately
     regardless of its own transition, cutting every child's fade short
     — same reasoning as the lang widget's own comment below. */
  .navbar.is-nav-open .navbar__phone,
  .navbar.is-nav-open .navbar__lead {
    opacity: 0;
    visibility: hidden;
    transform: translateX(-16px);
  }
  .navbar__phone,
  .navbar__lead {
    transition: opacity 0.4s cubic-bezier(0.76,0,0.24,1), visibility 0.4s cubic-bezier(0.76,0,0.24,1), transform 0.4s cubic-bezier(0.76,0,0.24,1);
  }
  .navbar.is-nav-open .navbar__nav {
    opacity: 1;
    visibility: visible;
    pointer-events: auto;
  }

  /* Corner slot — logo when closed, the language dropdown when open,
     same position. Both occupy the same grid cell (like nav/closed-
     controls above) so the swap is a pure cross-fade with no layout
     shift; the cell's own width is set by whichever of the two is
     wider, so it doesn't jump between states either. */
  .navbar__corner {
    display: grid;
    flex-shrink: 0;
  }
  /* 0.4s / cubic-bezier(0.76,0,0.24,1) — same duration+easing as the
     preloader's own overlay transition (partials/preloader.js), reused
     here per spec ("the same easing as the preloader") so the logo's
     fade and the language widget's slide-in (below) read as one
     coordinated move rather than two independently-timed fades. */
  .navbar__logo,
  .navbar__lang-widget--corner {
    grid-area: 1 / 1;
    transition: opacity 0.4s cubic-bezier(0.76,0,0.24,1), visibility 0.4s cubic-bezier(0.76,0,0.24,1);
  }
  /* Grid items stretch to fill their cell by default on BOTH axes
     (justify-self and align-self both default to stretch). The cell's
     own width is set by whichever occupant is widest — usually the
     language widget (~65px) — so once scrolled, the wordmark
     (.navbar__logo-full, ~76px at this height) was being sized against
     the stretched container rather than its own content, overflowing
     the (unclipped, before the fix above) box on the right.
     align-self:stretch also doesn't just do nothing when the item has
     an explicit height (28px, not auto) that prevents it from actually
     stretching — per spec it falls back to aligning the item to the
     START of the cell rather than centering it, which is why the logo
     was sitting flush against the top of the (taller, ~44px) cell
     instead of vertically centered in it. Both fixed by sizing/
     positioning the logo to its own content on both axes. */
  .navbar__logo {
    justify-self: start;
    align-self: center;
  }
  .navbar__lang-widget--corner {
    display: flex;
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
  }
  .navbar.is-nav-open .navbar__logo {
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
  }
  .navbar.is-nav-open .navbar__lang-widget--corner {
    opacity: 1;
    visibility: visible;
    pointer-events: auto;
  }
  /* Closed state (default): shows in .navbar__closed-controls, next to
     the phone icon, same as desktop. Open state: rather than just
     cross-fading in place like the corner instance's logo↔widget swap
     above, this one visibly SLIDES left into the logo's vacated spot —
     per spec ("the language switcher smoothly slides left into the
     logo's position") — while fading out, so by the time it's gone the
     corner instance (already fading in, same final position) has taken
     over. --slide-x/--slide-y are set inline by initMobileNavToggle()
     in script.js from a live getBoundingClientRect() delta between this
     widget and the corner slot, recomputed on every open (and on resize
     while open) so it lines up correctly at any viewport width without
     hardcoded geometry. Falls back to translate(0,0) — a plain fade —
     if JS hasn't set them yet for any reason. */
  .navbar__lang-widget--closed {
    transition: opacity 0.4s cubic-bezier(0.76,0,0.24,1), visibility 0.4s cubic-bezier(0.76,0,0.24,1), transform 0.4s cubic-bezier(0.76,0,0.24,1);
  }
  .navbar.is-nav-open .navbar__lang-widget--closed {
    opacity: 0;
    visibility: hidden;
    pointer-events: none;
    transform: translate(var(--slide-x, 0px), var(--slide-y, 0px));
  }

  /* Reduced motion — the corner logo/widget cross-fade and the closed
     widget's slide both collapse to an instant swap, no glide, no fade
     ramp. Same convention as the other transition:none overrides in
     this file (e.g. .stats__item above). */
  @media (prefers-reduced-motion: reduce) {
    .navbar__logo,
    .navbar__lang-widget--corner,
    .navbar__lang-widget--closed,
    .navbar__phone,
    .navbar__lead {
      transition: none;
    }
  }

  /* Logo — mirrors desktop's is-scrolled mark↔full swap (same trigger,
     same assets), just at the mobile pill's smaller scale. Previously
     this forced the full wordmark permanently visible (mark:
     display:none, full: opacity:1 unconditional) — no swap ever
     happened on mobile regardless of scroll position. */
  .navbar__logo { width: auto; height: 28px; }
  .navbar__logo-mark {
    display: block;
    position: static;
    width: 28px;
    height: 28px;
    opacity: 1;
  }
  .navbar__logo-full {
    display: none;
    position: static;
    width: auto;
    height: 28px;
    opacity: 1;
    pointer-events: none;
    transform: none;
  }
  .navbar.is-scrolled .navbar__logo-mark { display: none; }
  .navbar.is-scrolled .navbar__logo-full { display: block; pointer-events: auto; }
  /* BUG FIX (live review #1), still needed with the swap restored:
     .navbar.is-scrolled .navbar__logo{width:120px} (desktop, base rules
     above) has higher specificity (2 classes) than the plain
     .navbar__logo{width:auto} above, so it would still force a
     120px-wide logo inside the compact pill once scrolled. Re-declared
     at equal specificity to hold mobile's own auto width (now correctly
     sizing to whichever of mark/full is the visible child, via
     display:none/block rather than an absolute-position overlay). */
  .navbar.is-scrolled .navbar__logo { width: auto; }

  /* phone (closed-controls row, now lang + phone + lead — the CSS
     comment at .navbar__closed-controls > * above already sized this
     row for all three): re-shown here since the desktop base rule now
     hides it (desktop dropped the phone icon entirely, see .navbar__phone
     base rule). No size override otherwise — the desktop rule's own
     44×44 is already what this breakpoint needs. A previous override
     (40×40, and font-size:12px/height:auto/padding:0 on .navbar__lang
     before it moved here) shrank tap targets below 44px — not something
     to repeat. The corner lang trigger inherits the same base 44×44
     with no override needed either; its dropdown panel is left-aligned
     under it (min-width:108px, style.css base rules above) — at 320px
     the corner sits close to the left edge, so this stays comfortably
     clear of the right edge too. */
  .navbar__phone { display: flex; }

  /* Desktop CTA pill hidden on mobile — .navbar__lead below (shown only
     here, matches .navbar__phone exactly) fills its role instead. */
  .navbar__cta { display: none; }

  .navbar__lead {
    display: flex;
    width: 44px;
    height: 44px;
    border-radius: 10px;
    background: rgba(255,255,255,0.1);
    align-items: center;
    justify-content: center;
  }

  .navbar__burger {
    display: flex;
    width: 44px;
    height: 44px;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
  }

  #services, #faq, #reviews, #stages {
    /* Mobile navbar footprint differs from desktop's (top:66/height:60
       → y=66–126 vs desktop's y=23–93) — same reasoning and buffer as
       the desktop rule above, and the same value #cases's own mobile
       rule already uses in cases-drum.css. #stages itself is reached
       via a JS-computed scroll at every width now (centers
       .stages__screen, not the section's own top edge — see the hero
       CTA's click handler, script.js), not this scroll-margin — kept
       here anyway for whatever else might link to #stages directly
       (typed URL, a future link) and fall back to plain anchor
       behavior. */
    scroll-margin-top: 140px;
  }

  /* HERO — rebuilt as a literal 393×852 canvas matching Figma's "iPhone 16
     - 5" frame (node 421:93), scaled via clamp(floor, calc(px / 393 *
     100vw), px) — floor = the value at a 320px reference width, ceiling =
     the literal Figma px, so the whole stack freezes at its intended size
     above 393px instead of continuing to grow toward the 768px breakpoint
     edge (same clamp-based convention .hero__word--zatyaguye already used
     before this rewrite). Every number below comes from a fresh
     get_design_context/get_metadata pull on the frame's own child nodes —
     421:94 (image), 421:116/421:122 (title + agency label), 421:177
     (buttons) — not the old flow-based layout, which had no connection to
     Figma's coordinates at all: that's why the title used to sit right
     under the header instead of near the image's lower edge, why the
     stack was stretched far taller than one screen, and why the stats
     numbers rendered at 48px against Figma's actual ~22px. */
  /* Shared scale basis for the whole hero+stats Figma-pixel clamp system
     below — every "calc(X / 393 * 100vw)" in .hero__* and .stats* now
     multiplies by var(--hero-scale-vw) instead of a bare 100vw. On any
     screen tall enough for its width, this resolves to plain 100vw and
     NOTHING changes from before — the min() picks the vw term. On a real
     short viewport (small 100svh — address bar visible at load, which
     DevTools' fixed device-height presets don't model), it falls back to
     an equivalent width derived from the actual visible height instead,
     so hero's container AND every absolutely-positioned child (title
     rows, CTA row) AND stats shrink together in the same proportion —
     preserving the ~5-6px margins already tuned between e.g. .hero's own
     height and .hero__cta-row's bottom edge. Shrinking only .hero's own
     height against this var while leaving children on a bare 100vw would
     clip .hero__cta-row against .hero's overflow:hidden instead of fixing
     anything. 393/890 (not 393/718 — .hero's own height alone) is the
     combined hero+stats reference height at the 393px Figma frame
     (718px hero + ~172px stats' own natural, unclamped height there) —
     the actual goal is hero+stats TOGETHER fitting inside 100svh, not
     just hero alone leaving stats to overflow past svh's bottom edge on
     its own regardless. (Stats' fixed 24px bottom padding doesn't scale
     with this var — an intentional rhythm value, not a Figma one — so
     the fit is a close approximation on the shortest real viewports,
     not exact to the pixel; verified close enough live.) */
  :root {
    --hero-scale-vw: min(100vw, calc(100svh * 393 / 890));
    /* 2026-10-15 round 2 — extra vertical compression, ON TOP of
       --hero-scale-vw above, for the title/paragraph/CTA cluster only
       (never font-size or horizontal values — those stay exactly as
       --hero-scale-vw already sizes them). --hero-scale-vw alone is a
       straight proportional scale calibrated against a 320px-WIDE
       floor; on a viewport that's short rather than narrow (a real
       phone with the address bar showing — 664/600/568px tall, still
       350–390px wide) it doesn't compress NEARLY enough, because width
       never dropped below ~375-390. Net result before this: the title
       cluster alone was landing 440–480px down before the paragraph
       even started, which is most of a 600–664px budget that also has
       to fit a paragraph and two 52px buttons.
       max(0px, 700px − 100svh) is 0 for any svh ≥ 700 (every normal
       phone height, incl. this same 390px-wide phone with its chrome
       collapsed) — so this changes NOTHING there, zero risk to the
       common case — and grows linearly only below 700svh, exactly the
       "address bar visible" zone this bug report is about. The ×1.8
       multiplier is empirically tuned (see .hero__title-row-- rules
       below) against the three viewports in the bug report
       (390×664/375×600/320×568) to land the CTA row's own bottom
       comfortably inside each, not hand-derived from Figma. */
    --hero-tight: max(0px, calc(700px - 100svh));
  }

  .hero {
    /* 2026-10-15 bug report round 2: the previous pass pinned
       .hero__mobile-image to a fixed 718px height to stop it from
       painting over the newly-added paragraph/CTA row — that stopped
       the occlusion but made the image a real flow-adjacent block
       again, pushing total hero height (and the stats row after it)
       down by however tall that fixed image was, on top of whatever
       the paragraph/CTA content itself needed. Net effect on a short
       real viewport: first screen is mostly empty image, buttons sit
       below the fold. Root fix: the image goes back to being a pure
       absolute background (see .hero__mobile-image below) that costs
       the flow NOTHING — .hero's height is now purely its own content
       (title/paragraph/CTA, see those rules — now also compressed on
       short viewports via --hero-tight above), with the image
       stretching to cover whatever that resolves to, never the
       reverse.
       No min-height:100svh floor (tried first, reverted) — content
       already stays within one real viewport on every required case
       (390×664/375×600/320×568 all land the CTA row's own bottom at or
       under innerHeight — see .hero__cta-primary/secondary), so a
       100svh floor added NOTHING there except dead empty-image space
       between the buttons and .hero's own bottom edge whenever content
       came in shorter than a full svh (600/568 did, by 58px/72px) —
       directly fighting the required 32–48px gap to the stats row
       right after (that gap is .stats's own top padding, clamp(39px,
       …,47.7px) — correct on its own as long as nothing ahead of it
       adds extra dead space). Height now simply follows content.
       min-height:0 and display:block (overriding the desktop base
       rule's min-height:720px and display:flex;align-items:center —
       this mobile block never reset either, since the old mobile
       min-height value was always ≥720px anyway, so the leak never
       showed) are NEW, required the moment height stopped being
       pinned above 720px: with flex+align-items:center still active,
       WebKit centers .hero__stage inside whatever height .hero
       resolves to — against a leaked min-height:720px floor, content
       (~683px tall) was being vertically centered inside a 720px box,
       adding a ~37px top offset that pushed every measurement down and
       broke the budget this whole round is built around. block +
       min-height:0 makes .hero plain top-aligned flow, height = its
       own content, exactly what every calc() above assumes. */
    height: auto;
    min-height: 0;
    display: block;
    /* hidden (not visible) — nothing needs to escape vertically any
       more (content establishes its own height via min-height+auto,
       same as before), and hidden now also clips .hero__vignette's
       intentional horizontal bleed (it's wider than any viewport by
       design) to .hero's own width, avoiding a horizontal scrollbar. */
    overflow: hidden;
    padding: 0;
    background: #060c0c;
  }
  .hero::after { display: none; } /* desktop's cover-image vignette; replaced
    by .hero__vignette below */

  .hero__mobile-image {
    display: block;
    position: absolute;
    /* inset:0 + z-index:0 (not a fixed height) — this is a BACKGROUND
       layer again: it costs .hero's flow nothing and simply stretches
       to cover whatever height .hero resolves to (min-height:100svh,
       or taller if content needs more room — see .hero above), instead
       of forcing that height itself like the fixed-height version did.
       The actual "buttons covered" bug this was fixing was a STACKING
       problem (this paints above static content by default, being
       position:absolute — see the painting-order note on
       .hero__paragraph/.hero__cta-row below), not a sizing one; fixing
       it by giving the image a fixed height was solving the wrong
       layer of the problem. z-index:0 makes that explicit instead of
       relying on default stacking + DOM order. pointer-events:none —
       this is a decorative backdrop sitting under real content
       (buttons included), never the hit target. */
    inset: 0;
    z-index: 0;
    pointer-events: none;
    /* Second gradient layer (new) — the title/paragraph/buttons now sit
       ON TOP of the image's lower portion instead of below a hard image
       edge, so that portion needs to read as "dark enough for white
       text" on its own, not rely on .hero__vignette (which is centered
       on the title higher up, not shaped for the paragraph/CTA zone).
       Fades from fully transparent (upper photo, untouched) to the
       section's own solid background colour by 85% down, so content
       below that point sits on flat #060c0c same as the old build, and
       content in the transition zone (roughly where the paragraph now
       falls) still gets strong enough contrast. CONFIRMED (not assumed)
       — see the font/vignette comments elsewhere in this file for the
       crop math this gradient is layered over, unchanged. */
    background:
      linear-gradient(to bottom, rgba(6, 12, 12, 0) 0%, rgba(6, 12, 12, 0.55) 58%, rgba(6, 12, 12, 0.92) 80%, #060c0c 100%),
      url('assets/images/hero-bg.jpg') center/cover no-repeat;
  }
  /* Figma node 421:95 — a "Mask group" sitting directly above the image
     and below the title in the frame's own z-stack: a solid #060B0C shape
     (see mask path fill) run through a ~84px Gaussian blur. This, not the
     crop, is what makes the title's part of the image read near-black in
     Figma — confirmed by compositing this exact asset (downloaded
     byte-identical from Figma's own asset server, not redrawn) over the
     same background at the same coordinates in an isolated test harness
     and pixel-sampling the result: RGB(9–10, 13–14, 14) under the title,
     matching the shape's own #060B0C fill almost exactly, versus RGB(88,
     88, 86) at the same x a few hundred px higher on the bare fabric —
     this exact node was entirely missing from the previous mobile build,
     which only had a guessed linear-gradient in its place. Position/size
     are node 421:95's own frame coordinates (x:-731, y:-178.875 — vertical
     center-aligned per the SVG's own blur-bleed height of 1297.75 vs. the
     node's nominal 962 — w:2278.28, h:1297.75), clamped the same way as
     everything else in this stage. */
  .hero__vignette {
    display: block;
    position: absolute;
    /* z-index:0 — same background layer as .hero__mobile-image
       (explicit now, rather than relying on default stacking +
       DOM-order-among-positioned-siblings to keep it under the real
       content below). */
    z-index: 0;
    left: clamp(-731px, calc(-731 / 393 * var(--hero-scale-vw)), -595.2px);
    top: clamp(-178.875px, calc(-178.875 / 393 * var(--hero-scale-vw)), -145.65px);
    width: clamp(1855px, calc(2278.28 / 393 * var(--hero-scale-vw)), 2278.28px);
    height: clamp(1056.7px, calc(1297.75 / 393 * var(--hero-scale-vw)), 1297.75px);
    max-width: none;
    pointer-events: none;
  }

  .hero__stage {
    position: relative;
    width: 100%;
    /* height:100% of .hero's own (now auto) height would resolve to
       0 — min-height:100% still gives the absolutely-positioned
       children (image/vignette/title, unaffected either way) the same
       floor as before, while letting the normal-flow paragraph/CTA
       row below actually establish the real height. */
    min-height: 100%;
    height: auto;
    margin-top: 0;
    transform: none !important;
    /* flow-root (2026-10-15 round 2) — establishes its own block
       formatting context, which stops margin collapsing. Without this,
       .hero__paragraph's (large, margin-top-based) vertical position
       collapses straight through .hero__stage's own top edge instead
       of landing inside it: since .hero now has overflow:hidden (see
       its own rule), which itself blocks the SAME collapse from
       escaping any further up, the collapsed margin was getting stuck
       as dead space between .hero's top and .hero__stage's top instead
       — pushing .hero__stage (and everything positioned relative to
       it, including the absolutely-positioned title rows) down by the
       paragraph's own margin-top, nearly 400px on some viewports.
       flow-root keeps that margin where it visually belongs: inside
       the stage, directly above the paragraph. */
    display: flow-root;
  }

  .hero__title-row {
    position: absolute;
    /* z-index:1 — explicit now (2026-10-15 round 2): puts this in the
       SAME real stacking layer as .hero__paragraph/.hero__cta-row
       below, above the image/vignette's z-index:0, instead of relying
       on default-stacking + DOM order, which is what silently broke
       once content order/position changed in the previous round. */
    z-index: 1;
    display: flex;
    width: clamp(288px, calc(354.16 / 393 * var(--hero-scale-vw)), 354.16px); /* node 421:116 group width */
    /* Was left:clamp(16px,...,19.57px) — a fixed offset from the LEFT
       edge, calibrated to Figma's 393px reference frame. At exactly
       393px that happens to look centered (19.57px left / ~19.3px
       right, coincidentally close), but at any wider mobile width the
       clamp ceiling caps the left offset while the stage keeps
       growing — measured 46.7px off-center at 440px (iPhone 16 Pro
       Max), not a rounding error. left:50% + translateX(-50%) centers
       this fixed-width row within the stage regardless of the stage's
       own width, instead of anchoring it to a left offset that was
       only ever correct at one specific width. Every internal position
       (the words' own gap/align-items below) is untouched — only
       where the row itself sits moves. */
    left: 50%;
    transform: translateX(-50%);
  }
  .hero__title-row--1 {
    /* − (--hero-tight × 1.8) — see --hero-tight's own comment above;
       floor dropped to 0 (was 448px, a width-only floor that never
       engaged on a viewport this short anyway — the short-viewport
       numbers below come entirely from the subtraction, not from
       hitting this floor). */
    top: calc(clamp(0px, calc(550.58 / 393 * var(--hero-scale-vw)), 550.58px) - (var(--hero-tight) * 1.8)); /* node 421:117 "Web" top, +56px (see .hero) */
    align-items: baseline;
    /* Sphere.x(156.74) − Web.x(19.57) − Web.width(128) = 9.17 */
    gap: clamp(7px, calc(9.17 / 393 * var(--hero-scale-vw)), 9.17px);
  }
  .hero__title-row--2 {
    /* Same subtraction as --1 above, same reasoning — identical
       subtracted amount on both rows keeps the gap between them
       exactly what --hero-scale-vw alone already gives it. */
    top: calc(clamp(0px, calc(596.26 / 393 * var(--hero-scale-vw)), 596.26px) - (var(--hero-tight) * 1.8)); /* node 421:121 "затягує" top, +56px (see .hero) */
    align-items: flex-start;
    justify-content: space-between;
  }
  .hero__word {
    position: static;
    line-height: 0.88; /* Figma + the shared desktop rule; the old mobile
      override of 0.9 was an unintended drift from both */
    text-align: left;
  }
  .hero__word--web,
  .hero__word--sphere,
  .hero__word--zatyaguye {
    /* Figma sizes all three words identically (50.749px) — the old rule's
       two different clamp()s landed ~43px (WEB/SPHERE) vs ~51px (ЗАТЯГУЄ)
       at 393px width, a mismatch Figma doesn't actually have. */
    font-size: clamp(41px, calc(50.749 / 393 * var(--hero-scale-vw)), 50.749px);
  }
  /* Figma actually stacks TWO copies of "Web"/"Sphere" at identical
     positions (nodes 421:117+421:119, 421:118+421:120): one blend-mode
     overlay copy underneath, and a SOLID (no blend) rgba(255,255,255,0.93)
     copy on top of it. The shared desktop rule this mobile block inherits
     only has the single blend-overlay treatment (no second solid layer),
     which on desktop's own crop looks intentional, but on mobile — with
     only one blended layer and no opaque layer above it — the text's
     visible brightness swings with whatever's directly underneath: over
     the image's lighter fabric folds it blends toward grey, which is the
     washed-out look reported. Replicating the exact two-layer stack would
     mean duplicating shared markup that desktop also uses; instead this
     turns the blend off for mobile only and applies the dominant top
     layer's own solid treatment directly, which is what actually reads
     as "white text" in Figma's screenshot since that opaque top copy is
     what's visible regardless of the layer beneath it. */
  .hero__word--web, .hero__word--sphere {
    mix-blend-mode: normal;
    opacity: 0.93; /* Figma: rgba(255,255,255,0.93) */
  }

  .hero__agency-label {
    position: static;
    width: clamp(59px, calc(72.53 / 393 * var(--hero-scale-vw)), 72.53px); /* node 421:122 */
    font-size: clamp(9px, calc(11.271 / 393 * var(--hero-scale-vw)), 11.271px);
    line-height: 0.67;
    text-align: right;
    white-space: normal;
    flex-shrink: 0;
  }
  /* 2026-10-15 bug report #2 — Figma's mobile frame has no body
     paragraph, but the reported fix explicitly asks for one here
     (below the title, above the CTAs), in the site's standard body
     copy style rather than more Figma-pixel values: normal flow (not
     position:absolute inside the Figma stage), left-aligned, 16px,
     var(--font-body) (see the desktop rule above for the Cyrillic-
     fallback bug var(--font-body) also fixes). margin-top clears the
     title's own lowest point (.hero__title-row--2, ЗАТЯГУЄ: top
     clamp(486,...,596.26) + line-height 0.88 at font-size
     clamp(41,...,50.749) ≈ bottom clamp(522,...,641)) plus a ~24px
     rhythm gap, same clamp scale as everything else in this stage so
     the gap itself doesn't change size relative to the rest at
     intermediate widths. */
  .hero__paragraph {
    display: block;
    /* position:relative + z-index:1 (still needed — NOT a leftover
       hack to remove, per 2026-10-15 round 2's own instruction to drop
       anything no-longer-needed): .hero__vignette/.hero__mobile-image
       are position:absolute z-index:0, which paints above static
       in-flow content by default regardless of DOM order — this stays
       positioned, now with an explicit z-index so it's not relying on
       DOM-order-among-positioned-elements (the thing that silently
       broke once already this round). Also still resets top/left — the
       desktop rule above sets top:306px;left:1273px for its own
       position:absolute layout; position:relative (unlike the old
       position:static) DOES honor those unless reset, which is exactly
       the earlier-this-round bug (paragraph shifted 1273px off-screen)
       — still a live risk, still needed. */
    position: relative;
    z-index: 1;
    top: auto;
    left: auto;
    width: auto;
    /* margin-top: natural --hero-scale-vw clearance (clears the title's
       own lowest point + rhythm gap, see the old comment above) minus
       the SAME --hero-tight compression the title rows themselves get
       (×1.8 — see --hero-tight's comment) so this paragraph follows the
       title cluster up the page by the same amount, keeping the gap
       between them visually constant instead of growing as the title
       moves up. */
    margin: calc(clamp(0px, calc(665 / 393 * var(--hero-scale-vw)), 665px) - (var(--hero-tight) * 1.8)) var(--pad-x) 0;
    /* font-family now var(--font-hero-accent)/Tiny5 (2026-10-15 font
       decision), inherited from the base rule above — not redeclared
       here on purpose. 15px / 1.4 are this round's explicit spec (was
       1.45) — sentence case preserved (text-transform:none, unchanged;
       mobile never went uppercase, unlike desktop). */
    font-size: 15px;
    line-height: 1.4;
    text-align: left;
    text-transform: none;
  }
  /* CTA row — 2026-10-15 bug report #1: was two tiny (33-40px tall,
     ~6-7px label) side-by-side buttons, absolutely positioned at a
     fixed top inside the Figma-pixel stage — both too small to read
     comfortably and, at the shortest real viewports, clipped against
     .hero's own height (see .hero's own comment above; this was the
     recurring cause). Now normal flow (follows .hero__paragraph
     directly, same var(--pad-x) inset as everywhere else on the site)
     and stacked instead of side-by-side, so .hero's auto-height always
     has room for them regardless of exact size — nothing here is
     magic-number-fitted against a fixed container any more. */
  .hero__cta-row {
    /* position:relative + z-index:1, same reason as .hero__paragraph
       just above — otherwise these buttons paint underneath
       .hero__vignette/.hero__mobile-image's z-index:0 backdrop. */
    position: relative;
    z-index: 1;
    display: flex;
    flex-direction: column;
    /* 12px (was 20px) — trimmed as part of this round's vertical
       budget (see --hero-tight above): the title/paragraph cluster
       above now compresses hard on short viewports, but this row's own
       gap above it was still a flat, unscaled value eating into that
       same short-viewport budget for no reason tied to content. */
    margin: 12px var(--pad-x) 0;
    gap: 12px;
  }
  .hero__cta-primary,
  .hero__cta-secondary {
    position: static;
    flex: none;
    width: 100%;
    /* 52px (was 56px) — explicit spec, 2026-10-15 round 2. */
    height: 52px;
    margin-top: 0;
    border-radius: 10px;
    font-size: 15px;
    line-height: 1.03;
    letter-spacing: 0;
  }

  /* STATS — 2×2 grid, numbers pulled from Figma's stats nodes
     (421:182–421:193, same "iPhone 16 - 5" / 393px reference frame as the
     hero above) instead of guessed — the old 48px/11px sizes are why the
     numbers rendered conspicuously oversized against the frame; Figma's
     actual sizes are ~22px/~9.7px. Top padding = the Figma-measured gap
     from .hero's bottom edge (662) to the first stat number's top (709.7),
     i.e. 47.7px; side padding reuses the same 48px inset as the buttons
     above, which the stats grid's own column positions confirm it shares
     (col2's x lines up with a 142/14/142 split of the same 48–346 span).
     Bottom padding (24px) has no Figma value to match — both mobile
     frames end right at this grid — so it stays a rhythm-based choice. */
  .stats {
    min-height: auto;
    height: auto;
    /* Same --hero-scale-vw basis as .hero above (see its definition) —
       stats needs to shrink in the same proportion on a short real
       viewport, not just hero, or the two would drift out of the
       relative sizing Figma actually has between them. */
    padding:
      clamp(39px, calc(47.7 / 393 * var(--hero-scale-vw)), 47.7px)
      clamp(39px, calc(48 / 393 * var(--hero-scale-vw)), 48px)
      24px;
  }
  .stats__stage {
    width: 100%;
    height: auto;
    transform: none !important;
    display: grid;
    grid-template-columns: 1fr 1fr;
    /* column-major fill (not the default row-major) reads the DOM order
       (7+, 100%, 27, 140+) as item1+item2 → column 1 (top+bottom),
       item3+item4 → column 2 — i.e. row 1 "7+ / 27", row 2 "100% /
       140+". */
    grid-auto-flow: column;
    grid-template-rows: repeat(2, auto);
    column-gap: clamp(11px, calc(14 / 393 * var(--hero-scale-vw)), 14px); /* same 14px the buttons use */
    row-gap: clamp(26px, calc(31.82 / 393 * var(--hero-scale-vw)), 31.82px); /* row2 number top (776.18) − row1 label bottom (744.36) */
  }
  .stats__item { position: static; display: flex; flex-direction: column; gap: 4px; }
  .stats__number,
  .stats__label { position: static; }
  .stats__number { font-size: clamp(18px, calc(22.2 / 393 * var(--hero-scale-vw)), 22.2px); top: auto; }
  /* white-space: normal (overriding the shared rule's nowrap) — the
     longer labels ("виконаних зобов'язань", "фахівців у команді") don't
     fit a 2×2 cell's half-width on one line; default word-wrapping
     breaks only at spaces, never mid-word. */
  .stats__label  { font-size: clamp(8px, calc(9.7 / 393 * var(--hero-scale-vw)), 9.7px); top: auto; white-space: normal; }

  /* ═══════════════════════════════════════════════════════════════════
     TEAM + SERVICES — ONE LAYERED SCENE, same model as desktop.
     Desktop pins .team__sticky for a scroll budget and, inside it, stacks
     two overlapping layers: .team__stage (the big "ОДНА КОМАНДА / ВЕСЬ
     DIGITAL" heading) as a background, and .services__stage (the cards)
     on top, partially covering it. initTeamRevealAnimation() scrubs a
     scroll-driven reveal: hold the text, float the cards up over it, then
     dim the heading to ~15% so the card text stays legible.
     Mobile keeps ALL of that — sticky budget, overlapping absolute layers,
     scrubbed reveal, heading-dim for legibility — only re-laid-out for a
     393px frame and with the desktop card fan swapped for the swipe
     carousel + dots kept from the previous pass. The mobile reveal is the
     isMobile branch of initTeamRevealAnimation in script.js. */
  .team {
    /* Scroll budget = 15vh of scrubbing (was 35vh) — services/team is
       frozen elsewhere in this pass, exception granted for this one fix:
       measured total scroll-to-first-visible-card was ~890px (hero+stats,
       both locked) + ~101px of in-pin scrolling before the cards start
       rising (CARDS_S=0.34 of the old 298px/35vh scrub budget) ≈ 990px
       before any service card was visible — flagged as "too much
       scrolling" even after this section's own earlier cuts (150vh→95vh
       desktop, 55vh→35vh mobile). Every phase in script.js's
       initTeamRevealAnimation (heading entrance, card rise, dim, settle,
       drift) is expressed as a FRACTION of this budget, not an absolute
       distance — shrinking just this number compresses the whole
       already-tuned choreography proportionally (same relative timing,
       shorter absolute scroll) without touching a single constant in the
       JS itself. New scrub is 128px at an 852px viewport (was 298px);
       cards now start rising ~52px into the pin instead of ~101px — total
       scroll-to-first-card drops to ~942px. (desktop: 195vh, untouched) */
    height: 115vh;
    padding: 0;
    margin: 0;
  }
  .team__sticky {
    position: sticky;
    top: 0;
    height: 100vh;
    height: 100svh;          /* small-viewport unit where supported (Safari 15.4+/Chrome 108+); 100vh fallback above */
    min-height: 540px;
    width: 100%;
    padding: 0;
    display: block;          /* children are absolute layers, not flex items */
    overflow: hidden;        /* clips the cards while they're floated below */

    /* One shared coordinate system so the heading, the carousel and the
       dots all derive from the same numbers.
       --hw          heading word size
       --head-block  ОДНА-top → last heading line's bottom, in --hw units
       --head-top    y of ОДНА's top — the whole block is vertically
                     centred in the sticky viewport (clamped below the
                     navbar pill), which is what "pin the text centred"
                     means; the JS entrance never touches this.
       --svc-top     card top: ~0.7 lines below the heading top, so "ОДНА"
                     (and a sliver of "КОМАНДА") peeks above the card the
                     way the type peeks over the desktop fan, without the
                     composition going bottom-heavy.
       --card-h      card height (tracks viewport height). */
    --hw:         clamp(37px, 11vw, 48px);
    /* ОДНА-top → last line's (--try/--napryamiv) bottom, in --hw units —
       used only to centre the block vertically (`--head-top`). 4 lines
       at a uniform 0.17·lh gap (matches desktop's 30/171 row rhythm):
       3 gaps of 1.12 to the last line's top (3.36) + its own 0.95 line
       box ≈ 4.3. Was 5.0 while the (now-removed) subhead sat below. */
    --head-block: 4.3;
    --head-top:   max(122px, calc(50% - var(--hw) * var(--head-block) / 2));
    --card-h:     clamp(272px, 45vh, 400px);
    /* Settled composition = just the card + dots on dark (the heading has
       faded fully out by then), so centre that group in the space below
       the navbar pill (~126). 32px ≈ dots + gap allowance; 28px backs out
       the stage's own top padding so it's the CARD that centres. */
    --svc-top:    max(126px,
                    calc(126px + (100% - 126px - var(--card-h) - 32px) / 2 - 28px)
                  );
  }

  /* ── HEADING LAYER — background, behind the cards ──────────────────
     Desktop overlaps two colour layers — white "ОДНА КОМАНДА", grey
     "ВЕСЬ DIGITAL" (--try/--napryamiv respectively) — the grey offset
     right, a recessive echo behind the white. At 1920px they share two
     rows; at phone width the grey stack drops to its own two lines below
     the white pair, each word sized by the shared --hw unit (no longer
     tied to matching KOMANДА's own width — ВЕСЬ/DIGITAL are shorter than
     the old ТРИ/НАПРЯМІВ pair). Kept deliberate by: both stacks flush-left
     to their own edge, a consistent 1.3·--hw rightward offset on the grey
     stack, one uniform line-gap (0.17·lh, = desktop's 30/171 rhythm) for
     all four lines, matched line-height. Reads top-to-bottom in order:
     ОДНА / КОМАНДА / ВЕСЬ / DIGITAL (--try before --napryamiv in this
     media query — see the 2026-10-15 comment on those two rules below).
     Grey #575d61 reads clearly behind
     the white (z-index 2 over 1). The whole layer fades fully out behind
     the cards by the end of the sequence. */
  .team__stage {
    position: absolute;
    top: 0;
    left: 0;
    right: 0;
    width: auto;
    height: 100%;            /* spans the sticky viewport — words centre against THIS */
    padding: 0 20px;
    transform: none !important;
    display: block;
    opacity: var(--team-stage-opacity, 1);
    z-index: 1;
    --head-slide: 56px;      /* entrance travel — smaller than desktop's 150px */
  }
  .team__word {
    position: absolute;
    font-size: var(--hw);
    line-height: 0.95;       /* matches desktop (was 1) */
    letter-spacing: normal;  /* matches desktop — no tracking */
    white-space: nowrap;
  }
  /* `left/right: auto` resets the desktop 1920-canvas left: values.
     Vertical rhythm: each line 1.12·--hw below the previous top (0.95 line
     box + 0.17 gap). Grey stack indented 1.3·--hw. */
  .team__word--odna      { left: 20px; right: auto; top: var(--head-top);                              color: #ffffff; z-index: 2; }
  .team__word--komanda   { left: 20px; right: auto; top: calc(var(--head-top) + var(--hw) * 1.12);     color: #ffffff; z-index: 2; }
  /* 2026-10-15 (reverted same day): back to "— весь digital" word order
     on mobile (try/ВЕСЬ at the earlier 2.24 slot, napryamiv/DIGITAL at
     3.36) — a same-day earlier pass had swapped these to "digital
     весь" to match a desktop copy change, but that desktop change was
     mobile-only in intent; desktop keeps "digital весь" (its own
     left: values above, untouched), only this mobile stack's order
     reverts. Still just a top: swap, no content/desktop position
     touched — DIGITAL still doesn't fit --try's narrower desktop slot
     (see its own left: comment above). Desktop's own reading isn't
     linear anyway (see the HEADING LAYER comment above) — only
     mobile's strict top-to-bottom stack is affected. */
  .team__word--try       { left: calc(20px + var(--hw) * 1.3); right: auto; top: calc(var(--head-top) + var(--hw) * 2.24); color: var(--color-text-muted-dark); z-index: 1; }
  .team__word--napryamiv { left: calc(20px + var(--hw) * 1.3); right: auto; top: calc(var(--head-top) + var(--hw) * 3.36); color: var(--color-text-muted-dark); z-index: 1; }

  /* ── CARDS LAYER — swipe carousel, overlaid on the heading ─────────
     Absolute overlay (NOT a block below the heading): during the pinned
     sequence it stays parked below the fold and rises up over the centred
     heading once the heading has settled. Native scroll-snap carousel —
     one card at a time, dots directly under it.
     touch-action:pan-x is load-bearing: it lets a vertical drag that
     starts on a card fall through to the page so the scroll-reveal keeps
     advancing instead of the carousel swallowing the gesture and the
     reveal appearing to stick (a bug we've hit before). */
  .services__stage {
    position: absolute;
    left: 0;
    right: 0;
    top: var(--svc-top);
    bottom: auto;
    width: 100%;
    height: auto;
    transform: none;         /* reveal JS slides this up from below on scroll */
    z-index: 3;
    display: flex;
    flex-direction: row;
    align-items: flex-start;
    gap: 16px;
    /* Side padding = the "peek" of the neighbouring card + scroll-padding
       so first/last snap centred. 28/16 top/bottom clears the tilted
       cards' painted bbox (2.5deg on ~330px ≈ 8px/side). */
    padding: 28px 28px 16px;
    scroll-padding-inline: 28px;
    perspective: none;
    pointer-events: auto;
    touch-action: pan-x;
    overflow-x: auto;
    overflow-y: hidden;      /* forced by overflow-x:auto anyway */
    scroll-snap-type: x mandatory;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
  }
  .services__stage::-webkit-scrollbar { display: none; }

  .service-card {
    position: static;
    flex: 0 0 auto;
    /* min(80vw, 330px) caps growth on its own — a `max-width:100%`
       safety-net here turned out to actively break sizing: in this
       overflow-x:auto flex container, `max-width:100%` on a padded
       border-box flex item resolved to something narrower than the
       stage's real content box (confirmed empirically — removing it was
       the fix, not a specificity/cascade issue with any other rule). */
    width: min(80vw, 330px);
    aspect-ratio: auto;
    /* Sized to hug its content with headroom to spare: the tallest stack
       (Розробка's ~123-char blurb wrapping to 4 lines at 320px) is ≈ 268px
       incl. padding, so min(290px) never clips, and the clamp keeps the
       card in proportion on taller phones without opening a big empty gap
       inside it. */
    height: var(--card-h);
    padding: 24px;
    gap: 12px;
    border-radius: 36px;
    overflow: hidden;              /* safety clip + rounds the illustration's top corners */
    /* transform: --card-tilt (static resting lean, per card below) +
       --reveal-y (small staggered slide, set by the reveal JS). Both
       default to no-op so the card is correct with JS off / reduced
       motion. Plain 2D rotate() only — affine, so text stays crisp at any
       angle. `scale` (the press bounce, base rule) composes on top without
       touching this. */
    transform: translateY(var(--reveal-y, 0px)) rotate(var(--card-tilt, 0deg)) !important;
    opacity: var(--reveal-o, 1);  /* staggered fade-in; default 1 = visible with JS off */
    touch-action: pan-x;
    scroll-snap-align: center;
    scroll-snap-stop: always;
  }
  /* Resting lean — a fraction of desktop's fan (10.27° / 5.36°). Enough to
     read as an intentional tilt, small enough that the card you're on
     doesn't look crooked or skew its text. */
  .service-card--design  { --card-tilt: 2.5deg; }
  .service-card--center  { --card-tilt: 0deg; }
  .service-card--traffic { --card-tilt: 1.4deg; }

  /* Lighter backdrop blur on mobile — 36px re-sampled every frame while
     three cards translate + fade is a real jank source on phones, and
     once the heading has faded out there's only flat #060c0c behind the
     card anyway so the difference is invisible. */
  .service-card--design,
  .service-card--center,
  .service-card--traffic {
    backdrop-filter: blur(18px);
    -webkit-backdrop-filter: blur(18px);
  }

  /* A prominent 3D-icon area (scales with viewport height) takes up the
     slack a tall card would otherwise leave between text and the bottom-
     pinned CTA. */
  .service-card__illustration {
    width: calc(100% + 48px);
    margin: -24px -24px 0;
    height: clamp(96px, 20vh, 200px);
    border-radius: 36px 36px 0 0;
  }

  /* Neutralize the desktop :hover treatment entirely on mobile — touch has
     no real hover, but some environments (a mouse on a touch laptop, this
     project's own desktop-browser QA tooling) can still trigger :hover at
     mobile widths, which otherwise flashes the card red and lifts/untilts
     it — jarring and not part of the mobile design intent. */
  .service-card--design:hover,
  .service-card--center:hover,
  .service-card--traffic:hover {
    background: rgba(39, 39, 39, 0.4) !important;
    border-color: #3d3d3d !important;
    box-shadow: 0 40px 80px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 255, 255, 0.08) !important;
    transform: translateY(var(--reveal-y, 0px)) rotate(var(--card-tilt, 0deg)) !important;
  }
  .service-card:hover .service-card__cta { gap: 8px; }
  .service-card:hover .service-card__cta svg { transform: none; }
  .service-card__title { font-size: clamp(22px, 6.2vw, 26px); }
  .service-card__description {
    font-size: clamp(10.5px, 2.9vw, 12px);
    line-height: 1.36;
    /* Full text shown — sizes above tuned so the longest blurb (Розробка's
       ~123 chars) fits the min card height even at 320×568. .service-card's
       overflow:hidden is only a last-resort guard. */
  }
  .service-card__cta { font-size: 13px; }

  .services__dots {
    position: absolute;
    left: 0;
    right: 0;
    /* 24px below the card: svc-top + stage top-padding (28) + card + 24 */
    top: calc(var(--svc-top) + 28px + var(--card-h) + 24px);
    bottom: auto;
    z-index: 4;
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 8px;
    will-change: transform, opacity;
    transition: opacity 0.18s ease-out, transform 0.14s ease-out;
    /* Fades in late in the reveal + slides with the carousel — the reveal
       JS sets opacity and transform. */
  }
  .services__dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: rgba(255, 255, 255, 0.28);
    border: none;
    padding: 0;
    cursor: pointer;
    transition: background 0.25s ease, width 0.25s ease, border-radius 0.25s ease;
  }
  .services__dot.is-active {
    width: 20px;
    border-radius: 4px;
    background: #ff2828;
  }

  /* GRID — 2 columns on mobile */
  body::before { background-size: 50vw 33.33vh; }

  /* Reduced motion — no scrubbed reveal, no parallax, no card lean (JS
     bails; static transforms cleared too). Collapse the sticky budget to a
     single normal-flow screen showing the settled end-state: cards at rest
     over a dimmed heading, same as the post-reveal frame. */
  @media (prefers-reduced-motion: reduce) {
    .team { height: auto; }
    /* explicit height (not auto) so .team__stage's height:100% and the
       words' top:50% still resolve and the heading stays centred. */
    .team__sticky { position: relative; height: 100svh; min-height: 560px; }
    /* Static composition: the heading stays readable behind the card
       (the animated version fades it fully out, but reduced-motion keeps
       the content visible) — dimmed, card overlapping its lower half. */
    .team__stage { opacity: 0.3; transform: none; }
    .services__stage { transform: none; opacity: 1; }
    .service-card { transform: none !important; }
    .services__dots { opacity: 1; transform: none; }
  }


  /* Light wrapper — seam rework (real bug, not just cosmetic): the old
     20px top/bottom margin exposed a strip of bare body background
     (dark — see :root's --color-bg-dark) above and below this wrapper,
     with a full 40px radius corner starting immediately at the far edge
     of that strip. On index.html specifically, that strip sits directly
     under the fixed header (position:fixed, floats over whatever's
     there) between the end of a real dark SECTION (.cases-drum) and this
     wrapper — reading as two disconnected pieces with the header
     stranded in the gap between them, exactly as reported. On
     faq/cases/reviews/404.html, the same 20px bottom margin created an
     identical dead strip below the footer before the page ends (their
     own margin-TOP is separately set inline per-page to clear the fixed
     header — untouched here, longhand beats this shorthand's margin-top
     component regardless of what's set below, so that clearance is
     unaffected by this change).
     Fix, applied uniformly to both edges for consistency: margin-top/
     bottom removed entirely (0) — dark flows straight into light with
     no dead strip — and the radius cut from 40px to a much smaller 16px,
     so the corner reads as a normal card-style detail rather than a
     dramatic swoop starting cold right at the seam. clip-path updated to
     match (was still referencing var(--radius-pill), the 80px DESKTOP
     value, never itself redefined for mobile — a pre-existing mismatch
     between this rule's own border-radius and clip-path that happened to
     be latent before; explicit now that the radius is actually changing
     here). */
  .light-wrapper {
    border-radius: 16px;
    clip-path: inset(0 round 16px);
    margin: 0 10px;
  }
  /* Short gradient fade layered right at each seam — the "soften it,
     don't just cut it" half of the fix. Clipped to the wrapper's own
     rounded corners for free via its overflow:clip (style.css, base
     rule) + clip-path above, so these never need their own matching
     radius. transparent (not an explicit light hex) lets the wrapper's
     real background show through underneath, same technique already
     used by .faq-item__scroll-fog / .team::before elsewhere in this
     file — correct regardless of either color ever drifting. */
  .light-wrapper::before,
  .light-wrapper::after {
    content: '';
    position: absolute;
    left: 0;
    right: 0;
    height: 14px;
    pointer-events: none;
    z-index: 1;
  }
  .light-wrapper::before {
    top: 0;
    background: linear-gradient(to bottom, var(--color-bg-dark), transparent);
  }
  .light-wrapper::after {
    bottom: 0;
    background: linear-gradient(to top, var(--color-bg-dark), transparent);
  }

  /* Reviews — index.html's embedded carousel. Desktop's 1920px stage is
     JS-scaled to fit; updateReviews() in script.js already hands off
     (transform:none, height reset) below 768px, so this block owns all
     of the mobile layout.
     REWORKED from the previous mobile pass's JS-transform pager (arrows
     + translateX, ported from the old desktop mechanism) to a NATIVE
     scroll-snap carousel — the same pattern .services__stage already
     uses (see that block): overflow-x:auto + scroll-snap-type:x +
     scroll-snap-align:center per card, dots synced to real scroll
     position by initReviewsMobileCarousel() in script.js (a near-copy
     of initServicesCarousel's nearest-to-center + click-to-scroll
     logic). Arrows are desktop-only now (hidden below); dots take
     their place on mobile, exactly mirroring how services has no
     arrow-equivalent on mobile either.
     Whole section is also budgeted to fit one mobile viewport: every
     vertical measurement below is vh-aware (not just vw), the same way
     .service-card's own height (--card-h) scales with viewport height,
     not just width — a narrow-but-tall phone and a wide-but-short one
     both get a size actually derived from their own available height. */
  .reviews__stage { width: 100%; transform: none !important; padding: clamp(20px, 3vh, 32px) 0; gap: clamp(12px, 2vh, 20px); }
  .reviews__header { padding: 0 var(--pad-x); gap: 4px; }
  .reviews__label { font-size: 12px; }
  /* overflow-wrap is the clipping fix: the heading's own <br> still
     forces "КЛІЄНТИ," / "ЯКІ ЗАЛИШИЛИСЬ" onto two lines, but at mobile
     widths the second line doesn't fit unbroken at any size that still
     reads as a heading — this lets it wrap again instead of clipping.
     Ceiling brought down from 38 to fit the one-screen budget. */
  .reviews__heading { font-size: clamp(22px, calc(26 / 393 * 100vw), 30px); overflow-wrap: break-word; }

  /* Native scroll-snap row — .reviews__track no longer gets a JS
     translateX; it's just a flex row the browser scrolls natively.
     Side padding on the WRAP (not a margin-left hack) is what creates
     the peek on both edges, same as .services__stage's own padding. */
  .reviews__carousel-wrap {
    margin-left: 0;
    padding: 0 var(--pad-x);
    padding-right: clamp(28px, calc(32 / 393 * 100vw), 40px);
    overflow-x: auto;
    overflow-y: hidden;
    scroll-snap-type: x mandatory;
    scroll-padding-inline: var(--pad-x);
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
    touch-action: pan-x;
  }
  .reviews__carousel-wrap::-webkit-scrollbar { display: none; }
  .reviews__track { gap: 16px; }
  .review-card {
    width: min(78vw, 300px);
    /* vh-aware, like .service-card's --card-h — shrinks on a shorter
       viewport instead of just clipping or forcing scroll past the
       section. Raised from an earlier 210–280px pass: at the low end
       the fixed-height children below (photo-area clearance, avatar
       row, link) were eating nearly the ENTIRE card, leaving the quote
       — the one thing actually being reviewed — a measured 5px of
       visible area. Every fixed child was trimmed AND the range raised
       together so there's a real quote window at every size, not just
       the ceiling. */
    height: clamp(250px, 40vh, 300px);
    min-height: 0;
    padding: 14px 16px 14px;
    gap: 6px;
    scroll-snap-align: center;
    scroll-snap-stop: always;
  }
  /* Invisible flex-item SPACER that exists purely to clear the video
     pill overlay (top:10px + 44px tall = 54px) so it doesn't sit on
     top of the avatar row below it — no photo content ever renders on
     mobile. .review-card__badge-space (the other of the pair) is fully
     collapsed; this is the one doing real work. */
  .review-card__badge-space { height: 0; }
  .review-card__photo-area { height: 54px; }
  .review-card__top { gap: 8px; }
  /* Smaller than desktop's 39px — the extra ~14px reclaimed goes
     straight to the quote window below, and initials stay legible at
     14px (the floor) inside a 30px circle. */
  .review-card__avatar { width: 30px; height: 30px; }
  /* Quote area is now the one flexible child in a FIXED-height card —
     restores an internal scroll (removed in the previous mobile pass,
     when the card itself was allowed to grow) as the safety net for
     whichever quote is longest, the same relationship desktop already
     has between its own fixed card height and scrollable quote. */
  .review-card__quote-wrap { flex: 1; min-height: 0; overflow-y: auto; }
  .review-card__quote { font-size: 13px; line-height: 1.35; } /* 13px: explicit 14px-floor exception for this section, fit takes priority */
  /* .review-card__link ("Читати повністю") removed entirely, mobile rule
     included — it went to a dead 404.html placeholder and, now that the
     quote scrolls internally (.review-card__quote-fog above), the full
     text was already one scroll away with nothing left unreachable. Its
     own font-size had also never been given a mobile reduction unlike
     the near-identical .vmodal__case-link (21px → 16px there), so it
     was rendering oversized on top of being redundant. */
  /* Both sat below the 14px floor on desktop (9px role/company, 12px
     initials) — raised here, same family/weight/colour, just large
     enough to clear it. */
  .review-card__role,
  .review-card__company-name { font-size: 14px; }
  .review-card__initials { font-size: 14px; } /* still fits comfortably in the smaller 30px circle — no need to relax this one */

  /* "Відео відгук" pill: was a fixed 120×40px box still carrying
     desktop's 23px icon + 15px label + 28px horizontal padding, which
     left nowhere near enough room for the label — hence the mid-word
     clip. width:auto sizes the pill to its actual content instead (the
     label itself is unchanged); height set to exactly the 44px tap-
     target floor (was 40px) — top:10px + 44px = 54px clearance, which
     is what .review-card__photo-area above reserves. */
  .review-card__video-btn { top: 10px; right: 12px; width: auto; height: 44px; padding: 0 16px; }

  /* Arrows are desktop-only now — dots (below) replace them on mobile,
     same split services has (arrows never existed there; dots are the
     only mobile nav). */
  .reviews__nav { display: none; }
  .reviews__dots {
    display: flex;
    justify-content: center;
    align-items: center;
    gap: 8px;
  }
  .reviews__dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: rgba(53, 53, 53, 0.22);
    border: none;
    padding: 0;
    cursor: pointer;
    transition: background 0.25s ease, width 0.25s ease, border-radius 0.25s ease;
  }
  .reviews__dot.is-active { width: 20px; border-radius: 4px; background: #ff2828; }

  /* Exact match to .cases-drum__cta's own mobile treatment (that
     component's own stylesheet, cases-drum/cases-drum.css) — same
     reference the rest of this pass's CTA buttons use. height and
     align-self:stretch/width:auto were already identical; two real
     gaps found comparing them directly:
     1. No horizontal inset at all (font-size aside, this is what
        "oversized" actually meant) — measured stretching to just 10px
        from the true viewport edge on each side, because .reviews__cta
        is a flex child of .reviews__stage (zero horizontal padding of
        its own) inside .light-wrapper, whose OWN 20px/10px margin was
        the only inset it was getting — 20px short of the --pad-x-ish
        inset every other element in this section has. Same formula
        already established for exactly this situation (.cta-banner__
        content, an earlier pass): cases-drum's own 39–48px final
        on-screen margin, computed here with light-wrapper's fixed 10px
        already subtracted out, landing at the identical final inset
        cases-drum sits at directly (no wrapper of its own to account
        for there).
     2. font-size was shrinking to 11px at the narrow end — cases-drum's
        own button never shrinks its text at all (flat 12px, inherited
        from the shared desktop base both buttons already use
        unmodified). Override removed entirely rather than matched, so
        both buttons now literally share one declaration for this
        property instead of two copies that happen to agree. */
  .reviews__cta {
    align-self: stretch;
    width: auto;
    height: clamp(44px, calc(56 / 393 * 100vw), 56px);
    margin: 0 clamp(29px, calc(48 / 393 * 100vw - 10px), 38px);
  }

  /* FAQ */
  .faq { height: auto; min-height: 100svh; }
  .faq__stage { width: 100%; transform: none !important; padding: 40px 0 60px; }
  .faq__header { padding: 0 var(--pad-x); }
  .faq__list { flex: none; height: auto; overflow-y: visible; padding: 0 var(--pad-x); }
  /* Was still desktop's 23.704px — never overridden for mobile before.
     Measured 4 lines at 393px, 3 even at a 14px flat floor at 320px,
     with the ORIGINAL longer sentence — text shortened too (index.html:
     dropped the trailing "розібратися" clause) to actually clear a
     2-line budget at 320px, not just font-size alone. clamp floor is
     the 14px accessibility convention used elsewhere in this file;
     ceiling (18px) is comfortably clear of 2 lines even at 768px, where
     it now fits on one. .faq__ask-btn (inline in the same paragraph)
     matches so the button doesn't look mismatched against the note. */
  .faq__footer-note { padding: 0 var(--pad-x); font-size: clamp(14px, calc(15 / 393 * 100vw), 18px); }
  .faq__ask-btn { font-size: clamp(14px, calc(15 / 393 * 100vw), 18px); }
  .faq-item__header { height: auto; min-height: 80px; padding: 16px 24px; }
  .faq-item.open .faq-item__header { padding-bottom: 8px; }
  /* Preview text + its divider are desktop-only real estate: at a 253px
     question column + flex:1 preview + divider + chevron all crammed into
     a ~305px content width, preview was measured getting ~78px at 20px
     font — rendering as 2-3 clipped characters. Question takes the full
     row instead; the answer text (shown on expand) already covers what
     the preview summarized. */
  .faq-item__question {
    flex: 1 1 auto;
    font-size: 14px;
    /* Base rule 2-line-clamps the question (with an implicit ellipsis)
       — right for desktop's fixed 253px column, wrong once mobile gives
       it the full row instead: a longer question ("Чи надаєте
       підтримку після запуску?") wraps to 3 lines at narrow widths and
       was getting clipped mid-word. Uncapped so it wraps to however
       many lines it actually needs. */
    -webkit-line-clamp: unset;
    overflow: visible;
  }
  .faq-item__preview,
  .faq-item__v-divider { display: none; }
  .faq-item__h-divider { width: auto; margin-left: 24px; margin-right: 24px; }
  .faq-item__answer-area { padding: 8px 24px 24px; gap: 16px; }
  .faq-item__answer { font-size: 15px; }
  /* Chevron: the base rule has no CSS size at all — it's whatever the
     <img width="66" height="66"> attributes say, which is fine inside
     desktop's 142px-tall header but oversized against mobile's ~80px
     row. Icon scaled down; the chevron's own box kept at a real ≥44px
     target even though the click handler is already bound to the whole
     .faq-item__header (script.js), not the chevron alone — the full
     row was already tappable, this doesn't change that. */
  .faq-item__chevron { width: 44px; height: 44px; }
  .faq-item__chevron img { width: 28px; height: 28px; }

  /* CTA Banner — text-only on mobile, portrait image dropped entirely
     (not just scrimmed over). Desktop's card is wide enough (max-width:
     1241px) that the right-aligned portrait sits in its own clear zone
     past the text; at mobile's ~353px card width that same image would
     span nearly the full card under the text regardless of how strong a
     scrim sits over it — dropping it outright, rather than dimming it
     further, is the cleaner fix. Solid background, same dark as the
     image's own base tone so the card still reads as the same surface
     as everywhere else in this dark section. */
  /* Side padding moved here (was 0) per explicit instruction: match the
     footer's own gutter, not the hero/cases-drum CTA-button inset this
     used before. .cta-wrap lives inside .light-wrapper (same as
     .footer does) which carries its own flat 10px side margin at this
     breakpoint — pixel-confirmed via getBoundingClientRect, not
     assumed — so var(--pad-x) here lands .cta-banner's own visible
     edge at wrapper(10) + pad-x(20) = 30px from the true screen edge,
     identical to where .footer__pill's own edge sits (.footer uses
     this exact padding: ... var(--pad-x) ... pattern on itself,
     confirmed live at 30px too) — genuinely the same gutter now, not
     two different formulas that used to land in the same rough
     neighbourhood. */
  .cta-wrap__stage { width: 100%; transform: none !important; padding: 20px var(--pad-x) 0; }
  .cta-banner {
    background: var(--color-bg-dark);
    background-image: none;
    min-height: auto;
    margin: 0;
    border-radius: 30px;
    padding: 0;
  }
  /* No Figma mobile frame exists for this banner — checked: the file's
     only two mobile frames ("iPhone 16 - 5" / "iPhone 16 - 6") are both
     hero/navbar mockups, nothing for the CTA banner or the footer
     below. This reflow (and the footer's) is adapted from the desktop
     composition instead, not pulled from a mobile design — same
     reflow-not-scale approach as the rest of this pass (see
     .cta-wrap__stage above: transform stays disabled on mobile, this is
     real layout, not a shrunk desktop render).
     Horizontal padding here is now the card's own INTERNAL breathing
     room (var(--pad-x) again, rather than a separate number) — the
     actual screen-edge gutter comes from .cta-wrap__stage above now,
     so this doesn't need to carry that job by itself any more the way
     the old clamp(29,...,38) formula did.
     max-width:360px (a fixed desktop-basis number unrelated to this)
     dropped — content width now simply follows from the padding.
     flex:1 1 auto + min-width:0 — without these, this flex column
     (only child of .cta-banner, itself display:flex) sizes itself to
     its own content's min-content width instead of the space actually
     available, since flex items don't shrink below that by default.
     Pixel-confirmed this was a REAL bug, not theoretical: at 393px the
     content box measured 423px wide against a 373px-wide banner —
     genuinely overflowing its own parent by 50px, which is exactly why
     the button read with a 58px left margin and a 7.7px right margin
     instead of equal margins on both sides. flex:1 1 auto lets it fill
     the banner's real available width; min-width:0 is what actually
     permits shrinking below the text's own preferred width. */
  .cta-banner__content {
    padding: 40px var(--pad-x);
    max-width: none;
    flex: 1 1 auto;
    min-width: 0;
  }
  /* Was 38px — fit fine against the old ~333px content width, but the
     narrower card this pass's gutter fix produces (393px card → 333px
     content minus the old padding, now ~293px) meant the second line
     ("ЗАТЯГНУТИСЬ?", one unbroken word) started overflowing — caught
     live via scrollWidth vs clientWidth, not assumed. A flat 33px
     cleared it at 393px but with zero margin (293 vs 293) and, being
     flat, gave no relief at 320px, where the same 33px against a
     narrower ~220px content width forced overflow-wrap into an ugly
     mid-word break ("ЗАТЯГНУТИ" / "СЬ?") — nothing clipped (the
     safety net worked) but not a clean line break either. Fluid
     instead: re-measured, 30px ceiling clears 393px with real margin
     (280px natural vs 293px available) and the floor keeps the word
     whole at 320px too (24.4px computed there → 216px natural vs
     220px available), confirmed via scrollWidth/clientWidth at both,
     not just visually. */
  .cta-banner__heading { font-size: clamp(24px, calc(30 / 393 * 100vw), 30px); }
  .cta-banner__sub { font-size: 15px; }
  /* Full-width, same side margins as the hero and cases-drum CTAs (see
     .cta-banner__content's own comment) — "all three match" per
     instruction. align-self:stretch overrides the desktop flex-start
     (which shrink-wraps to the button's own content width); height
     matches .cases-drum__cta's own mobile clamp, comfortably clearing
     the 44px tap-target minimum at every width tested (48–56px). */
  .cta-banner__btn {
    align-self: stretch;
    width: auto;
    height: clamp(48px, calc(56 / 393 * 100vw), 56px);
  }

  /* Footer — same "no Figma mobile frame, adapted from desktop" note as
     the CTA banner above applies here too. Reflow only: every element
     present on desktop (wordmark, copyright, all 4 social icons, all 5
     legal links, tagline) is still here — nothing cut, nothing
     summarised — just restacked, with spacing and tap targets sized for
     touch instead of a mouse cursor. Typography (family/weight/colour)
     is untouched throughout; only sizes that were failing the 14px
     text / 44px tap-target minimums were raised. */
  .footer {
    padding: 56px var(--pad-x) 64px;
    /* pill → legal nav gap — was 14px, sized for the single desktop row;
       too tight once the pill itself becomes a multi-row stack below. */
    gap: 32px;
  }
  .footer__pill {
    flex-direction: column;
    align-items: flex-start;
    /* brand block → social+tagline block */
    gap: 28px;
    padding: 28px 24px;
  }
  .footer__right { gap: 18px; }
  /* Icons keep their existing 24×24 visual size (unchanged — layout
     change, not a restyle) — the link's own box grows to the 44×44
     minimum instead, so the artwork doesn't get blown up to hit the
     target size. 16px gap (unchanged from desktop) between 44px boxes
     reads as a real gap, not four icons touching. */
  .footer__social { gap: 16px; }
  .footer__social-link { width: 44px; height: 44px; }

  /* Legal nav — REWORKED per explicit instruction, overriding the
     previous round's wrapped-stack approach: was flex-wrap:wrap, each
     link its own 44px-tall cell, so 5 links ("Публічна оферта" through
     "Контакти") wrapped across several rows with a full 44px of height
     (mostly invisible padding) stacked between each — reading as a
     sparse list, not a compact one. Single row instead — nowrap +
     horizontal scroll, same pattern as the navbar's own nav row — so
     there's no more wrapping to create row-gaps in the first place.
     Left-aligned (was centred), width:100% still meaningful for that.
     Tap target kept at a real 44px via min-height (invisible padding,
     not a visible box) — doesn't fight "compact" since it's one row:
     the extra height is padding above/below the shared baseline, not
     stacked rows of it. */
  .footer__nav {
    flex-wrap: nowrap;
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
    justify-content: flex-start;
    width: 100%;
    gap: 20px;
    padding: 0;
  }
  .footer__nav a {
    font-size: 14px;
    white-space: nowrap;
    flex-shrink: 0;
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }

  /* Modal — shared by all 4 (the consultation modal + the 3 service
     lead modals: partials/modal.js, partials/lead-modals.js). Was
     still clipping "ЗАТЯГНУТИСЬ?" at narrow widths despite this
     block's own padding/font-size already being smaller than desktop
     — 28px side padding + 36px font left ~246px for a ~280px-wide
     word at 342px viewport (estimated from the display font's
     character width), no wrapping possible since it's one unbroken
     word and overflow-wrap wasn't set. Fixed with both a fluid
     font-size (so it's already smaller at the narrowest widths, not
     just below its 34px ceiling) AND overflow-wrap:break-word on the
     base rule above as a hard guarantee regardless of exact sizing.
     var(--pad-x) for the side padding — same responsive side-inset
     used everywhere else on mobile — frees a little more width back
     than the previous fixed 28px did at 320px. */
  .modal {
    padding: 32px var(--pad-x);
    border-radius: 28px;
    min-height: auto;
  }
  .modal__star { font-size: 24px; }
  /* Screenshot-verified at 320px: the previous clamp(26px, 34/393*100vw,
     34px) put "ЗАТЯГНУТИСЬ?" at ~27.7px there, just wide enough to need
     a mid-word break (overflow-wrap doing its job — nothing clipped —
     but an ugly line 3 rather than a clean 2). Shrinking the ratio
     (34→30) and the floor (26→22) gets the same word onto 2 lines at
     320px too, confirmed by re-shooting the same screenshot after this
     change. */
  .modal__heading { font-size: clamp(22px, calc(30 / 393 * 100vw), 34px); }
  /* margin-bottom: 20px (old, mobile-only value) dropped — the base
     rule's 24px (2026-10-15 bug report #3) now applies uniformly, same
     as every other modal variant; only font-size stays mobile-specific
     here. */
  .modal__sub { font-size: 16px; }
  /* Real bug, caught live: .modal__input's flex:1 (flex-basis:0%) is
     harmless on desktop, where .modal__fields is flex-direction:row —
     flex-basis governs the main axis there (width), leaving height:62px
     (the cross axis) alone. On mobile, flex-direction flips to column,
     so the MAIN axis becomes vertical — flex-basis:0% now hijacks
     HEIGHT instead, and the flex algorithm ignores the height property
     entirely once flex-basis is a non-auto value. Measured result: the
     name field silently collapsed to ~20px (its own min-content size)
     regardless of height:62px, even with an inline !important override
     — the property was never being consulted at all, not a specificity
     problem. flex:none on every direct child (name input, and the
     phone field's .iti wrapper) opts both out of the flex algorithm's
     sizing entirely on mobile, where nothing needs to grow/shrink in a
     single stacked column anyway — each just keeps its own explicit
     height. (The .iti wrapper itself was never actually broken by
     this, since it has no explicit height of its own to be ignored —
     its child input's height still sets its content-based auto
     min-height — but flex:none here keeps it consistent with its
     sibling instead of leaving one flexible and one not.) */
  .modal__fields { flex-direction: column; }
  .modal__fields > * { flex: none; }
  /* Required order top-to-bottom: heading, sub, name, phone, channels,
     submit — submit last. Desktop's own side-by-side submit-left/
     channels-right stays untouched (this block only applies ≤768px);
     `order` flips the two visually without touching DOM/tab order or
     the shared markup. */
  .modal__bottom-row { flex-direction: column; align-items: stretch; gap: 16px; }
  .modal__channels { order: 1; flex-direction: column; align-items: flex-start; gap: 12px; }
  .modal__submit { order: 2; width: 100%; }
  .modal__check-label { font-size: 14px; }

  .modal__success { min-height: auto; padding: 12px 0; }
  .modal__success-heading { font-size: clamp(22px, calc(30 / 393 * 100vw), 34px); }
  .modal__success-text { font-size: 15px; }

  /* ═══════════════════════════════════════════════════════════════════
     VIDEO REVIEW MODAL (vmodal) — had no mobile treatment at all before
     this: both variants (880×816 horizontal, 839×683 portrait) are
     fixed-px desktop compositions that don't reflow, and the row-
     reverse portrait layout (info left, video right) has nothing to
     stack into. Same rules as the lead/consultation modals: fits the
     viewport with consistent gutters, nothing clipped, internal scroll
     if taller than the screen. Both variants forced to the SAME single
     -column composition here (video on top, info below) — the only
     difference kept between them on mobile is the video area's own
     aspect ratio, matching each variant's own desktop proportions
     (horizontal: wide/short; portrait: taller). */
  /* align-items:center — was flex-start in an earlier pass, to keep
     the close button (then a fixed offset from the backdrop's own top
     edge) visually hugging the card's corner regardless of the card's
     height. Per direction the card centers vertically instead now;
     the close button's positioning was reworked to match (JS-computed
     from the card's own live rect, not a fixed backdrop offset — see
     placeCloseBtn() in script.js), so it still hugs the card's actual
     corner at any height/position, centered or not. Combined with
     .vmodal's own max-height + overflow-y below: a short card centers
     with room on both sides; a tall one grows to the max-height
     ceiling (also centered, so scroll room is split evenly) and
     scrolls internally. */
  .vmodal-backdrop { padding: var(--pad-x); align-items: center; }
  .vmodal {
    width: 100%;
    max-width: 100%;
    max-height: calc(100vh - var(--pad-x) * 2);
    border-radius: 24px;
    /* Was overflow:hidden (desktop clips instead of scrolling — fine
       there since the fixed 880/839px boxes were sized to fit their
       own content). Mobile's variable viewport heights need the real
       scroll — the close button is taken out of this scrolling flow
       below (position:fixed) rather than fighting to keep it "stuck"
       inside a scrolling container. */
    overflow-y: auto;
    flex-direction: column !important; /* forces portrait's row-reverse into a stack too */
  }
  /* Both variant classes set a fixed desktop height (816px / 683px) —
     caught live: with that still applying, the modal was pinned to
     ~812px (816 capped by max-height) regardless of how short the
     actual content was, instead of sizing to it. height:auto lets the
     modal size to its real content, with max-height above only
     stepping in as an actual ceiling when content is genuinely taller
     than the viewport. */
  .vmodal--horizontal,
  .vmodal--portrait { height: auto; }
  /* .vmodal's own open/close animation (base rule) is
     transform:translateY(...) — a transform on any ancestor creates a
     new containing block for position:fixed descendants, silently
     turning "fixed to the viewport" into "fixed to .vmodal instead"
     (spec behaviour, not a bug in the fixed positioning itself). Caught
     live: the close button — meant to stay put regardless of scroll —
     was measured moving off-screen as the card scrolled, exactly
     tracking scrollTop, because it was actually pinned to the
     scrolling .vmodal, not the viewport. transform:none here removes
     that containing block so .vmodal__close's position:fixed below
     resolves against the real viewport like it's supposed to. Costs
     the slide-up-16px part of the open animation on mobile only — the
     backdrop's own opacity fade (unaffected, lives on a different
     element) still gives a real transition. */
  /* transform:none stops one of the two containing-block triggers (see
     below) but backdrop-filter — the actual frosted-glass look — is
     the other, and isn't something to drop just for this. So the real
     fix is JS-side (initVideoModal's placeCloseBtn, script.js): under
     this breakpoint the close button is moved to be .vmodal-backdrop's
     own direct child instead of .vmodal's, on every open. This rule
     targets it there — position:absolute against the backdrop (itself
     position:fixed;inset:0, with neither transform nor backdrop-filter
     of its own) keeps it genuinely anchored to the viewport regardless
     of .vmodal's internal scroll.
     top/right below are a static fallback only — script.js now sets
     both inline on every open (and on resize while open), computed
     from the card's actual getBoundingClientRect() so the button hugs
     the card's real corner wherever it lands once centering (above)
     replaced the old fixed top-of-backdrop offset these values used to
     be tuned for. */
  .vmodal { transform: none !important; }
  .vmodal-backdrop > .vmodal__close {
    position: absolute;
    top: calc(var(--pad-x) + 10px);
    right: calc(var(--pad-x) + 10px);
    width: 44px;
    height: 44px;
    z-index: 20;
  }
  .vmodal__close svg { width: 11px; height: 11px; }

  .vmodal__video-area {
    width: 100% !important;
    height: auto !important;
    margin: 0 !important;
    border-radius: 20px 20px 0 0;
    flex-shrink: 0;
  }
  .vmodal--horizontal .vmodal__video-area { aspect-ratio: 830 / 416; }
  .vmodal--portrait .vmodal__video-area { aspect-ratio: 361 / 420; } /* shorter than desktop's 361/558 — full 558 height would eat most of a phone screen before any info shows */
  .vmodal__play-btn { width: 56px; height: 56px; }
  .vmodal__play-btn svg { width: 16px; height: 19px; }

  .vmodal--horizontal .vmodal__info,
  .vmodal--portrait .vmodal__info {
    width: 100%;
    padding: 20px var(--pad-x) 24px;
    gap: 14px;
    /* Base rule is flex:1 inside .vmodal's column layout — harmless on
       desktop (.vmodal has a fixed height there, so info fills exactly
       its own share), but caught live here: once .vmodal's height comes
       from max-height/overflow instead of a fixed value, flex:1 stretched
       info to fill ALL unused space below the video area (measured 635px
       for content that only needed ~270px), leaving a large dead gap
       between the quote and "Переглянути кейс". flex:none lets it size
       to its own content instead, matching every other single-column
       mobile card on this site. */
    flex: none;
  }
  .vmodal__avatar { width: 44px; height: 44px; border-width: 1px; }
  .vmodal__initials { font-size: 15px; }
  .vmodal__name { font-size: 18px; }
  .vmodal__role { font-size: 14px; margin-top: 2px; }
  .vmodal__quote { font-size: 15px; line-height: 1.4; }
  /* Neither variant's own quote clipping applies once both share one
     mobile layout: portrait's fixed 380px quote box and horizontal's
     base-rule overflow:hidden (desktop-only there, needed since
     horizontal's whole modal doesn't scroll — mobile's does) both gave
     way to the outer .vmodal scroll (above), which is what actually
     handles overflow now. Without this, the horizontal variant was
     silently clipping any quote taller than its flex-allotted space —
     caught live: content measured exactly at the modal's own
     max-height ceiling, which turned out to be the clip boundary, not
     a coincidence of the content actually fitting. */
  .vmodal__quote-wrap { flex: 1; height: auto; overflow: visible; }
  .vmodal__quote-bar { display: none; } /* both variants — the outer scrollbar is the only one now, no need for the accent bar's own */
  .vmodal__case-link {
    font-size: 16px;
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }
}

/* ===================== 404 PAGE ===================== */
.not-found {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  background: #060c0c;
  padding: 120px 20px;
  text-align: center;
}
.not-found__inner { max-width: 520px; }
.not-found__code {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(120px, 20vw, 240px);
  line-height: 1;
  color: #ff2828;
  margin: 0 0 20px 0;
}
.not-found__title {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(24px, 4vw, 40px);
  color: #fff;
  text-transform: uppercase;
  margin: 0 0 16px 0;
}
.not-found__description {
  font-family: var(--font-body);
  font-size: 16px;
  line-height: 1.5;
  color: rgba(255, 255, 255, 0.7);
  margin: 0 0 32px 0;
}
.not-found__cta {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 56px;
  padding: 0 32px;
  background: #ff2828;
  color: #fff;
  border-radius: 10px;
  text-decoration: none;
  font-family: var(--font-body);
  font-weight: 600;
  font-size: 14px;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  transition: background 0.2s ease;
}
.not-found__cta:hover { background: #ae1b1b; }

/* ===================== FAQ PAGE (faq.html) ===================== */
.faq-main {
  overflow: hidden;
  position: relative;
}

.faq-main__stage {
  width: 1920px;
  transform-origin: top left;
  box-sizing: border-box;
  padding: 120px 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 70px;
}

/* ---- Hero visual area ---- */
.faq-main__hero {
  position: relative;
  width: 100%;
  height: 251px;
  overflow: clip;
  flex-shrink: 0;
}

/* FAQ letters + magnifying-glass icon — Figma-exported SVG, node 85:7347 */
.faq-main__faq-group {
  position: absolute;
  left: 200px;
  top: 6px;
  width: 441.036px;
  height: 238.899px;
  display: block;
  pointer-events: none;
}

/* SPHERE watermark — Figma-exported SVG, node 85:7340 */
.faq-main__sphere {
  position: absolute;
  left: 843.64px;
  top: 7.3px;
  width: 876.906px;
  height: 175.483px;
  display: block;
  pointer-events: none;
}

.faq-main__subtitle {
  position: absolute;
  left: 612.27px;
  top: 13.95px;
  font-family: var(--font-display);
  font-size: 21px;
  font-weight: 700;
  color: #445156;
  text-transform: uppercase;
  letter-spacing: 1.05px;
  line-height: 1.34;
  width: 221px;
  text-align: center;
}

/* ---- Filter + List row ---- */
.faq-main__body {
  display: flex;
  gap: 23px;
  align-items: flex-start;
}

.faq-filter {
  flex: 0 0 390px;
  background: #ececec;
  border-radius: 20px;
  padding: 18px 60px 35px;
  display: flex;
  flex-direction: column;
  gap: 18px;
  align-self: flex-start;
}

.faq-filter__heading {
  display: flex;
  align-items: center;
  gap: 10px;
  height: 56px;
  flex-shrink: 0;
}

.faq-filter__label {
  font-family: var(--font-body);
  font-size: 21.138px;
  font-weight: 600;
  color: var(--color-text-muted);
  line-height: 0.97;
}

.faq-filter__list {
  display: flex;
  flex-direction: column;
  gap: 8px;
  max-height: 330px;
  overflow-y: auto;
  scrollbar-width: thin;
  scrollbar-color: #ff2828 transparent;
}

.faq-filter__list::-webkit-scrollbar { width: 3px; }
.faq-filter__list::-webkit-scrollbar-track { background: transparent; }
.faq-filter__list::-webkit-scrollbar-thumb { background: #ff2828; border-radius: 10px; }

.faq-filter__btn {
  display: flex;
  align-items: center;
  width: 270px;
  height: 50px;
  padding: 8px 94px 8px 23px;
  background: #f5f5f5;
  border: none;
  border-radius: 10px;
  font-family: var(--font-body);
  font-size: 15px;
  font-weight: 600;
  color: var(--color-text-muted);
  text-align: left;
  cursor: pointer;
  transition: background 0.2s ease, color 0.2s ease;
  box-sizing: border-box;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.faq-filter__btn:hover {
  background: #e8e8e8;
  color: var(--color-text-muted);
}

.faq-filter__btn.active {
  background: #ffffff;
  color: var(--color-text-muted);
}

/* Scrollable FAQ list */
.faq-main__list-wrap {
  flex: 0 0 1087px;
  position: relative;
}

.faq-main__list {
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 10px;
  max-height: 787px;
  overflow-y: auto;
  scrollbar-width: none;
}

.faq-main__list::-webkit-scrollbar { display: none; }

/* Custom proportional scroll indicator (Figma 85:7422) */
.faq-scroll-bar {
  width: 3px;
  height: 787px;
  background: #d3d2d2;
  border-radius: 10px;
  position: relative;
  flex-shrink: 0;
  align-self: flex-start;
}

.faq-scroll-bar__thumb {
  position: absolute;
  left: 0;
  top: 0;
  width: 3px;
  height: 0;
  background: #ff2828;
  border-radius: 10px;
}

.faq-main__list-fog {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 120px;
  background: linear-gradient(to bottom, transparent, #d9d9d9);
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.3s ease;
  z-index: 1;
}
.faq-main__list-fog.is-visible { opacity: 1; }

.faq-item.faq-item--filtered { display: none; }

/* ---- Footer note ---- */
.faq-main__footer-note {
  font-family: var(--font-body);
  font-size: 23.704px;
  font-weight: 600;
  color: var(--color-text-muted);
  line-height: 0.97;
  text-align: center;
  white-space: nowrap;
}

.faq-main__ask-btn {
  background: none;
  border: none;
  padding: 0;
  font-family: var(--font-body);
  font-size: 23.704px;
  font-weight: 600;
  color: #ff2828;
  cursor: pointer;
  line-height: inherit;
}

.faq-main__ask-btn:hover { text-decoration: underline; }

/* Mobile — kept as its own block right here, not folded into the single
   @media(max-width:768px) block above, because this desktop .faq-main
   section itself sits AFTER that block in the file (pre-existing —
   404/FAQ/reviews/case-card were all appended past it at some point).
   At equal specificity CSS resolves ties by source order, not by which
   media query the rule is textually inside, so a .faq-main__stage
   override placed in the earlier block would lose to this later
   unconditional one regardless of viewport — confirmed live (measured
   width:1920px still winning at a 393px viewport before this was moved
   here). Placing the override after its desktop rule the same way the
   main block already does for everything above it fixes that; nothing
   else was touched, so 404/reviews/case-card keep whatever behaviour
   they already had.
   Checked Figma for a mobile frame first; the MCP Figma tool is
   unavailable this session (server disconnected mid-round), so this is
   adapted straight from the desktop composition instead, not pulled
   from a mobile design. Reflow, not scale — faq.html's own inline
   scaler already disables the stage transform under 768px (see the
   <script> at the bottom of that file), so this is real layout,
   matching the same approach used everywhere else on mobile. Filter
   sidebar stacks above the list; .faq-item itself reuses the shared
   mobile rules in the main block (nothing separate needed there — it's
   the same component on both this page and the index.html teaser). The
   list's own internal scroll (787px max-height + a custom 3px scrollbar
   thumb, built for a fixed-height desktop column) is dropped in favour
   of the page just scrolling — same pattern .faq__list already uses —
   so the now-redundant custom scrollbar and its bottom fog overlay are
   hidden too. */
@media (max-width: 768px) {
  .faq-main__stage { width: 100%; padding: 40px 0 60px; gap: 32px; }
  .faq-main__hero {
    height: auto;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 12px;
    padding: 0 var(--pad-x);
    text-align: center;
  }
  /* The SPHERE watermark is pure decoration — aria-hidden, alt="",
     pointer-events:none, no semantic content — sized for an 1920px
     canvas (877px wide). Dropped here rather than cramming an
     oversized watermark into a ~350px column; the FAQ-group icon (the
     more on-topic of the two, and still real visual identity for the
     page) stays, resized and centred above the subtitle. The subtitle
     itself — actual text content — is untouched apart from position
     and size. */
  .faq-main__sphere { display: none; }
  .faq-main__faq-group {
    position: static;
    width: min(220px, 60vw);
    height: auto;
  }
  .faq-main__subtitle {
    position: static;
    width: auto;
    font-size: 22px;
  }

  .faq-main__body { flex-direction: column; gap: 24px; padding: 0 var(--pad-x); }
  .faq-filter {
    flex: none;
    width: 100%;
    box-sizing: border-box;
    padding: 16px 20px 20px;
  }
  .faq-filter__list { max-height: none; overflow-y: visible; }
  .faq-filter__btn { width: 100%; box-sizing: border-box; padding: 8px 20px; }

  .faq-main__list-wrap { flex: none; width: 100%; }
  .faq-main__list { max-height: none; overflow-y: visible; }
  .faq-scroll-bar,
  .faq-main__list-fog { display: none; }

  .faq-main__footer-note {
    padding: 0 var(--pad-x);
    font-size: 16px;
    white-space: normal;
  }
}

/* ===================== REVIEWS PAGE ===================== */

.reviews-main {
  overflow: hidden;
  position: relative;
}

.reviews-main__stage {
  width: 1920px;
  transform-origin: top left;
  box-sizing: border-box;
  padding: 120px 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 70px;
}

/* ----- Hero watermark ----- */
.reviews-main__hero {
  display: flex;
  gap: 73px;
  align-items: center;
  height: 251px;
  overflow: clip;
  flex-shrink: 0;
  width: 100%;
}

.reviews-main__watermark {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 208.826px;
  line-height: 0.88;
  color: #b5b5b5;
  text-transform: uppercase;
  white-space: nowrap;
  flex-shrink: 0;
}

.reviews-main__watermark--primary {
  padding-left: 191px;
}


/* ----- Filter + Sort bar ----- */
.reviews-main__controls-wrap {
  display: flex;
  flex-direction: column;
  gap: 10px;
  width: 1538px;
}

.reviews-main__controls-spacer {
  height: 58px;
  flex-shrink: 0;
}

.reviews-main__controls {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
}

/* ===================== HUB CHIPS + SORT (cases.html + reviews.html) =====================
   Shared by both hub pages — see assets/hub.js for the markup/logic
   that uses these classes (hubRenderChips/hubRenderSortToggle). Chips
   WRAP (flex-wrap), never clip/scroll-fade — the old .chip-row
   horizontal-scroll mechanism (still in this file, generic, unused by
   anything after this change) is deliberately not used here anymore. */
.hub-controls-wrap {
  display: flex;
  flex-direction: column;
  gap: 14px;
  width: 1538px;
}
.hub-controls {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 14px;
  width: 100%;
}
.hub-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  align-items: center;
}
.hub-chip {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  height: 50px;
  padding: 8px 23px;
  min-width: 128px;
  border-radius: 10px;
  border: none;
  cursor: pointer;
  font-family: var(--font-body);
  font-size: 15px;
  font-weight: 600;
  white-space: nowrap;
  background: #fff;
  color: var(--color-text-muted);
  text-decoration: none;
  transition: background 0.18s ease, color 0.18s ease;
  box-sizing: border-box;
}
.hub-chip.active {
  background: #ff2828;
  color: #fff;
}
.hub-chip:hover:not(.active) {
  background: #eee;
  color: var(--color-text-muted);
}
/* Disabled chips — genuinely inert (<span>, no href, not in the tab
   order — see hubRenderChips in assets/hub.js), not just styled to
   look disabled. No hover/active state at all. */
.hub-chip--disabled {
  cursor: default;
  color: var(--color-text-faint);
  background: #f0f0f0;
}
.hub-chip .hub-icon {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  overflow: visible;
}

/* Sort toggle — deliberately NOT a third .hub-chip: a neutral grey
   segmented control, visually distinct from the red category chips,
   per explicit requirement. */
.hub-sort {
  display: inline-flex;
  background: #ececec;
  border-radius: 10px;
  padding: 4px;
  gap: 4px;
}
.hub-sort__btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  height: 42px;
  padding: 0 20px;
  border: none;
  border-radius: 7px;
  background: transparent;
  color: var(--color-text-muted);
  font-family: var(--font-body);
  font-size: 15px;
  font-weight: 600;
  cursor: pointer;
  transition: background 0.18s ease, color 0.18s ease;
}
.hub-sort__btn.active {
  background: #fff;
  color: #445156;
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12);
}
.hub-sort__btn:hover:not(.active) { color: #445156; }

/* Mobile sort button + dropdown (2026-10-15 hub-chips redesign) —
   hidden at base/desktop by default; the @media block further down
   shows this and hides .hub-sort instead. Desktop's own .hub-sort
   above is completely untouched by this addition. */
.hub-sort-mobile { display: none; position: relative; }
.hub-sort-mobile__btn {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  flex-shrink: 0;
  border: none;
  border-radius: 10px;
  background: #fff;
  color: var(--color-text-muted);
  cursor: pointer;
}
.hub-sort-mobile__btn[aria-expanded="true"] { background: #eee; color: #445156; }
.hub-sort-mobile__menu {
  position: absolute;
  z-index: 20;
  top: calc(100% + 8px);
  right: 0;
  min-width: 200px;
  background: #fff;
  border-radius: 12px;
  box-shadow: 0 8px 28px rgba(0, 0, 0, 0.22);
  padding: 6px;
}
.hub-sort-mobile__item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  min-height: 44px;
  padding: 0 12px;
  border: none;
  border-radius: 8px;
  background: transparent;
  font-family: var(--font-body);
  font-size: 15px;
  font-weight: 600;
  color: var(--color-text-muted);
  text-align: left;
  cursor: pointer;
}
.hub-sort-mobile__item:hover { background: #f2f2f2; }
.hub-sort-mobile__item.active { color: #445156; }
.hub-sort-mobile__check {
  display: flex;
  width: 14px;
  flex-shrink: 0;
  color: var(--color-accent);
  visibility: hidden;
}
.hub-sort-mobile__item.active .hub-sort-mobile__check { visibility: visible; }

/* Service tags (cards) — same 3 keys/colors everywhere, see
   HUB_SERVICE_META in assets/hub.js. Icon-text gap is exactly 8px per
   explicit spec (was 10px on the old per-page tag chip). */
.hub-tag {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 8px 14px;
  border-radius: 121px;
  font-family: var(--font-body);
  font-size: 14px;
  font-weight: 600;
  color: #fff;
  flex-shrink: 0;
  box-sizing: border-box;
}
/* 2026-10-14 finalization pass: darkened from #2cbca0/#2a917d (same
   hue/saturation, lightness only) — white text on the old teal was
   2.38:1, failing WCAG's 4.5:1 for normal-size text. #1f8470 clears it
   at 4.6:1 (ΔE76 ≈ 22 from the original — border darkened by the same
   proportion it already was relative to its own fill, #1d6557). */
.hub-tag--design  { background: #1f8470; border: 1px solid #1d6557; }
.hub-tag--dev      { background: #2861ff; border: 1px solid #2247af; }
.hub-tag--traffic  { background: #ff2828; border: 1px solid #b10a0a; }
.hub-tag .hub-icon { width: 16px; height: 16px; flex-shrink: 0; }

/* Views badge — top-right corner of the card (case cards and review
   cards both use this, hence its own generic name rather than being
   scoped to either component). */
.hub-card__views {
  position: absolute;
  top: 16px;
  right: 18px;
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-family: var(--font-body);
  font-size: 14px;
  font-weight: 600;
  /* #5a686d-family (rgba(90,104,109,x)) measures under 4.5:1 against this
     card's #d3d2d2 background at any usable alpha — the exact bug
     .review-card__quote's own color already fixed once in this file
     (see its comment: "#3d474a ... just dark enough to clear it").
     Same hue, same background, same fix, applied here too. */
  color: var(--color-text-muted);
  z-index: 1;
}
.hub-card__views-icon { width: 14px; height: 14px; flex-shrink: 0; }

/* ----- Card grid ----- */
.reviews-main__grid-wrap {
  position: relative;
  width: 1538px;
  max-height: 964px;
  overflow: hidden;
}

.reviews-main__grid-wrap.is-expanded {
  max-height: none;
  overflow: visible;
}

.reviews-main__grid {
  display: grid;
  grid-template-columns: repeat(3, 500px);
  gap: 18.6px;
}

/* Taller cards in grid context (Figma: 554px vs carousel 440px) */
.reviews-main__grid .review-card {
  height: 554px;
}
/* 2026-10-15 bug report — this card never had its own padding override,
   so it was silently inheriting the CAROUSEL's unscoped mobile rule
   (.review-card, further down this file: 14px 16px 14px) even on
   desktop width whenever this selector's specificity tied with that
   rule's own media query — in practice, desktop measured the full
   20px/60px/60px base rule instead, nowhere near "equal top/side", and
   mobile measured the carousel's 14/16/14. Both decoupled now from the
   carousel entirely with an explicit value of their own. Desktop stays
   closer to its prior side padding (60px) since nothing asked for it
   to move, just for the (now separately fixed, see __badge-space/
   __photo-area and __footer below) empty gap above the avatar row to
   go away. */
.reviews-main__grid .review-card { padding: 20px 60px 60px; }

/* ---- Hub-grid-only review card additions ----
   Everything below is scoped to .reviews-main__grid specifically so
   index.html's carousel (same .review-card/__quote*/__video-btn classes,
   unscoped rules above, untouched) never sees any of this.

   Quote truncation moves from the old scroll+fog mechanism (JS-toggled
   .scrolls class, see .review-card__quote-wrap/__quote-fog above) to a
   plain 5-line clamp + conditional "Читати повністю" link (reviews.html
   decides whether to render the button, based on whether the quote
   actually overflows 5 lines / a fullText exists) — simpler, and
   matches the "Читати повністю" new requirement directly instead of
   reusing a scroll affordance that was never meant to signal "there's
   more, click through". */
.reviews-main__grid .review-card__quote-wrap {
  overflow: visible;
  flex: none;
}
.reviews-main__grid .review-card__quote-fog { display: none !important; }
.reviews-main__grid .review-card__quote {
  display: -webkit-box;
  -webkit-line-clamp: 5;
  -webkit-box-orient: vertical;
  overflow: hidden;
  line-height: 1.3;
}

.reviews-main__grid .review-card__readmore-btn {
  display: inline-flex;
  align-items: center;
  background: none;
  border: none;
  padding: 0;
  margin-top: 2px;
  font-family: var(--font-body);
  font-size: 15px;
  font-weight: 600;
  color: #ff2828;
  cursor: pointer;
  align-self: flex-start;
  transition: opacity 0.18s ease;
}
.reviews-main__grid .review-card__readmore-btn:hover { opacity: 0.75; }

/* Service tags + date/views footer — new to review cards (the old 12
   hand-written cards had neither); tags reuse the shared .hub-tag
   system (assets/hub.js hubRenderTags, same as case cards), footer
   mirrors .cases-grid-card__footer/__date. Views badge itself is
   .hub-card__views (defined with the HUB chips/sort rules above) —
   .review-card is already position:relative so it anchors correctly
   with no extra rule needed here. */
.reviews-main__grid .review-card__tags {
  display: flex;
  flex-wrap: wrap;
  gap: 7px;
  margin-top: 8px;
}
.reviews-main__grid .review-card__footer {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: 6px;
}
.reviews-main__grid .review-card__date {
  font-family: var(--font-body);
  font-size: 14px;
  font-weight: 600;
  /* Same contrast fix as .hub-card__views above — rgba(90,104,109,x)
     fails 4.5:1 on this card background. */
  color: var(--color-text-muted);
  white-space: nowrap;
}
/* Same small separator dot already used in .review-card__role-row. */
.reviews-main__grid .review-card__footer-dot {
  flex-shrink: 0;
  width: 1.5px;
  height: 1.5px;
  border-radius: 50%;
  background: #4a4a4a;
}
/* Views — 2026-10-15 bug report: moved here from .hub-card__views (an
   absolutely-positioned top-right corner badge, still used as-is by
   case cards — this override is review-card-only) onto one line with
   the date instead, "DD.MM.YYYY · 👁 views". Same font/weight/color as
   the date next to it for a single consistent meta line. */
.reviews-main__grid .review-card__views {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-family: var(--font-body);
  font-size: 14px;
  font-weight: 600;
  color: var(--color-text-muted);
  white-space: nowrap;
}
.reviews-main__grid .review-card__views .hub-icon { width: 14px; height: 14px; flex-shrink: 0; }

/* Extra cards (7–12): hidden until load more */
.review-card--extra {
  display: none;
}

.reviews-main__grid-wrap.is-expanded .review-card--extra {
  display: flex;
}

/* Filtered-out cards — overrides expanded state */
.review-card--filtered {
  display: none !important;
}

/* Bottom fade hinting at more content */
.reviews-main__grid-fade {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 200px;
  background: linear-gradient(to bottom, rgba(217,217,217,0), #d9d9d9);
  pointer-events: none;
  transition: opacity 0.4s ease;
}

.reviews-main__grid-wrap.is-expanded .reviews-main__grid-fade {
  opacity: 0;
}

/* ----- Load more button ----- */
.reviews-main__load-more {
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
  font-family: var(--font-body);
  font-size: 23.704px;
  font-weight: 600;
  color: var(--color-text-muted);
  line-height: 0.97;
  white-space: nowrap;
  transition: color 0.18s ease;
}

.reviews-main__load-more:hover { color: #445156; }

.reviews-main__load-more.is-hidden { display: none; }

/* ===================== CASE CARD COMPONENT ===================== */

/* Root is now an <a> (whole card links to /cases/<slug>/) rather than a
   <div> — position:relative so .hub-card__views can sit in its corner,
   and text-decoration/color reset since <a> isn't styled by default. */
.cases-grid-card {
  position: relative;
  width: 500px;
  height: 614px;
  background: #d3d2d2;
  border-radius: 20px;
  padding: 24px 60px 60px;
  display: flex;
  flex-direction: column;
  gap: 9.301px;
  overflow: hidden;
  box-sizing: border-box;
  flex-shrink: 0;
  text-decoration: none;
  color: inherit;
  transition: opacity 0.18s ease;
}
.cases-grid-card:hover { opacity: 0.92; }

/* Service tags row — individual tags are .hub-tag (assets/hub.js
   hubRenderTags), this is just the flex wrapper around them. */
.cases-grid-card__tags {
  display: flex;
  gap: 7px;
  align-items: center;
  flex-wrap: wrap;
  flex-shrink: 0;
  overflow: hidden;
}

/* ---- Case card body: cover + tags + title + result + excerpt ----
   Replaces the old author/quote (testimonial-shaped) body entirely —
   case cards never had a real author or a quote; that content was
   reused from reviews data, which is exactly the "mixed data" bug
   this round fixes. See assets/hub.js's HUB_SERVICE_META for tag
   colors/icons (shared with review cards, same 3 keys). */
.cases-grid-card__cover {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 10;
  background: #c8c7c7;
  border-radius: 8px;
  flex-shrink: 0;
  overflow: hidden;
}
.cases-grid-card__cover img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
/* Branded placeholder (2026-10-14) — was a flat #c8c7c7 box when
   `cover` is null; now a brand-tinted gradient with the logo mark
   centered as a watermark, so an empty case card still reads as part
   of this site rather than a generic broken-image grey rectangle. */
.cases-grid-card__cover--placeholder {
  background:
    url('assets/images/logo-mark-88.png') center / 40px 40px no-repeat,
    linear-gradient(135deg, #cfcecb 0%, #c3c2bf 100%);
}

/* Views badge — a child of .cases-grid-card__cover now (was the card's
   own corner) — "inside the cover, top-right" per explicit spec. The
   shared .hub-card__views rule (position:absolute, top/right) anchors
   to whichever ancestor is position:relative, which is now the cover
   itself, not the whole card — no change needed there, just the DOM
   move plus this cover now being position:relative. */

.cases-grid-card__content {
  display: flex;
  flex-direction: column;
  gap: 8px;
  flex-shrink: 0;
  overflow: hidden;
}

.cases-grid-card__title {
  font-family: var(--font-display);
  font-size: 21px;
  font-weight: 700;
  color: #445156;
  text-transform: uppercase;
  line-height: 1.15;
}

/* Short metric line ("+180% заявок за 3 місяці") — accent color per
   explicit spec, visually the "headline result" of the case. */
.cases-grid-card__result {
  font-family: var(--font-body);
  font-size: 16px;
  font-weight: 700;
  color: var(--color-accent);
  line-height: 1.3;
}

.cases-grid-card__excerpt {
  font-family: var(--font-body);
  font-size: 15px;
  font-weight: 500;
  /* #5a686d measures under 4.5:1 against this card's #d3d2d2 background
     — same contrast fix already applied elsewhere in this file for the
     identical hue/background pair (see .review-card__quote's comment). */
  color: var(--color-text-muted);
  line-height: 1.35;
  overflow: hidden;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
}

/* Views badge moved to the card's top-right CORNER (.hub-card__views,
   position:absolute — see HUB chips/sort block above), not the footer,
   per explicit spec. Footer now holds only the date. The old "Читати
   кейс →" link is gone — the whole card is the <a> now. */
.cases-grid-card__footer {
  display: flex;
  align-items: center;
  padding: 9.301px;
  flex-shrink: 0;
  /* Pins to the card's bottom edge now that .cases-grid-card__body (the
     flex:1+justify-end wrapper that used to do this for the group) is
     gone — same visual result, no extra DOM layer. */
  margin-top: auto;
}

.cases-grid-card__date {
  font-family: var(--font-body);
  font-size: 14px;
  font-weight: 600;
  /* Same contrast fix as .hub-card__views above. */
  color: var(--color-text-muted);
  white-space: nowrap;
}

/* ---- Cases grid wrapper (own class — doesn't conflict with reviews grid) ---- */
.cases-main__grid-wrap {
  position: relative;
  width: 1538px;
  max-height: 614px;
  overflow: hidden;
}

.cases-main__grid-wrap.is-expanded {
  max-height: none;
  overflow: visible;
}

.cases-main__grid {
  display: grid;
  grid-template-columns: repeat(3, 500px);
  gap: 18.6px;
}

.cases-main__grid-fade {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 200px;
  background: linear-gradient(to bottom, rgba(217,217,217,0), #d9d9d9);
  pointer-events: none;
  transition: opacity 0.4s ease;
}

.cases-main__grid-wrap.is-expanded .cases-main__grid-fade { opacity: 0; }

.cases-grid-card--extra    { display: none; }
.cases-main__grid-wrap.is-expanded .cases-grid-card--extra { display: flex; }

/* ===================== CASES PAGE — MOBILE =====================
   Previously had zero mobile CSS at all: hero watermark, filter/sort
   chips, the 3-column card grid and load-more all rendered at literal
   desktop width, left unclipped-but-unreachable past the right edge of
   a narrow viewport (.reviews-main itself has overflow:hidden, so this
   wasn't even a scroll-to-see-it problem — the content past 393px was
   just gone from view entirely).

   Figma has no mobile frame available for this page this session (MCP
   Figma tool unavailable) — adapted straight from the desktop
   composition below, same as faq.html's own mobile section (see its
   comment, above the main shared @media block) had to do for the same
   reason. Reflow, not scale: the page's own inline stage-scaler
   (bottom of cases.html) already bails to transform:none under 768px;
   this is real layout taking over from there, not a shrunk copy of the
   1920px canvas.

   Placed here, right after this component family's own desktop rules
   (not inside the single shared @media block near the top of this
   file) for the exact reason documented at .faq-main__stage's own
   mobile section: these desktop rules are themselves declared past
   that shared block in file order, and at equal specificity CSS
   resolves ties by source position, not by which media query a rule is
   textually inside — an override placed earlier in the file would
   silently lose to these later unconditional desktop rules regardless
   of viewport. Keeping the override here, after its desktop rule, the
   same way faq.html's own section already does, avoids repeating that
   bug. */
@media (max-width: 768px) {
  /* .chip-row/__track/__fog* (old horizontal-scroll-with-fade affordance)
     intentionally has no mobile rule here anymore — cases.html/
     reviews.html no longer render that markup for category chips (they
     wrap via .hub-chips now, never clipped/faded, per explicit spec);
     the generic .chip-row desktop CSS and initChipRowFog() in script.js
     are left in place (harmless, unused) rather than hunted down site-
     wide, but this mobile duplicate of it is dead weight in the exact
     block being rewritten here, so it goes. */

  /* ---- Stage / hero watermark ----
     Watermark text ("КЕЙСИ") is this hero block's ONLY content — unlike
     .faq-main__sphere (hidden on mobile), this isn't a secondary
     decorative flourish alongside a real heading elsewhere; it IS the
     page's heading here, so it stays, just resized to fit instead of
     removed, per "no content removed." clamp() floor/ceiling chosen so
     a 5-letter word reads clearly as a title at 320px without
     overwhelming a 440px one. */
  .reviews-main__stage {
    width: 100%;
    padding: 40px 0 60px;
    gap: 32px;
  }
  .reviews-main__hero {
    height: auto;
    padding: 0 var(--pad-x);
    gap: 0;
    /* Bug fix (2026-10-16, real-screenshot report): base rule's
       `overflow: clip` exists for the DESKTOP fixed-height (251px) box,
       to trim anything taller than that row. On mobile height is auto
       (hugs the single watermark line exactly) so there's nothing left
       for it to usefully clip — and at line-height 0.88 the line box is
       actually a few px shorter than the font's own real glyph extent,
       so with height:auto + overflow:clip the ascender/diacritic on
       tall Cyrillic letters (e.g. Й's breve in "КЕЙСИ") got clipped off
       — measured 3-4px of ink past the box top across the 320-430px
       range via Range.getBoundingClientRect() on the real text node,
       confirmed visually ("КЕЙСИ" was rendering as "КЕИСИ"). Desktop's
       own overflow:clip (fixed 251px height, real purpose) is untouched. */
    overflow: visible;
  }
  /* Full contrast on mobile — this watermark IS the page's only heading
     here (title bar doesn't exist separately), so the decorative grey-
     on-grey desktop look (#b5b5b5 on the same #d9d9d9-family background)
     reads as nearly invisible at small size; desktop itself is untouched
     per "may change ONLY where listed." Same dark as every other title/
     heading on the site (.cases-grid-card__title etc.), not a new color. */
  .reviews-main__watermark {
    font-size: clamp(40px, 15vw, 64px);
    white-space: normal;
    color: #445156;
  }
  .reviews-main__watermark--primary { padding-left: 0; }

  /* ---- Chips + sort controls ----
     2026-10-15 redesign — was two full rows: chips wrapping across 2
     lines, then the segmented Нові/Популярні toggle full-width below
     them ("looks clumsy", explicit bug report). Now one row: a single-
     line horizontal chip scroller + a 44×44 sort icon button fixed at
     the end (opens a small dropdown — .hub-sort-mobile/__menu above).
     .hub-controls-wrap drops its own side padding entirely (was
     var(--pad-x) both sides) so the chip scroller can bleed all the
     way to the physical right edge of the screen as it scrolls — real
     requirement, not a side effect — while .chip-row below supplies
     the LEFT inset itself (first chip still lands at var(--pad-x)) and
     .hub-sort-mobile supplies its own right margin so the tap target
     stays properly inset regardless. Spacer dropped same as before
     (see prior comment, still accurate — the hero block's height is
     fluid now, nothing left for that fixed gap to mean). */
  .hub-controls-wrap {
    width: 100%;
    padding: 0;
    gap: 14px;
  }
  .reviews-main__controls-spacer { display: none; }
  .hub-controls {
    flex-direction: row;
    align-items: center;
    flex-wrap: nowrap;
    gap: 10px;
    width: 100%;
  }
  /* .chip-row/__fog-- — reuses the SAME class names (and therefore the
     SAME existing initChipRowFog() in script.js, zero new JS) as the
     generic horizontal-scroll-fade affordance already built for this
     exact pattern elsewhere on the site — "shared CSS/JS, no
     duplication" per explicit spec, not a parallel implementation. */
  .chip-row {
    position: relative;
    flex: 1 1 auto;
    min-width: 0; /* lets this shrink below its content's natural width so overflow-x actually has something to scroll — the classic flex-item gotcha */
    overflow: hidden; /* clips the fade overlays to this box; the actual scrolling happens on .hub-chips (="chip-row__track") inside it */
  }
  .hub-chips {
    /* Single line, no wrap, real horizontal scroll — was flex-wrap,
       which is exactly the "2 rows" bug being fixed here. */
    display: flex;
    flex-wrap: nowrap;
    width: auto;
    overflow-x: auto;
    scroll-snap-type: x proximity;
    -webkit-overflow-scrolling: touch;
    /* Hidden scrollbar — Firefox + WebKit/Blink, explicit requirement. */
    scrollbar-width: none;
    -ms-overflow-style: none;
    /* padding, not margin, on the scrolling element itself: the first
       chip's content starts at var(--pad-x) from the viewport edge,
       but the scrollable AREA (and therefore how far it can bleed
       right) is the full flex:1 width of .chip-row, unconstrained. */
    padding: 0 4px 0 var(--pad-x);
    /* Bug fix (2026-10-16, real-screenshot report): hubInitActiveChipScroll()
       (assets/hub.js) calls active.scrollIntoView({inline:'nearest'}) on
       load. Without scroll-padding, the browser only considers the FIRST
       CHIP's own border-box "in view" once it's flush with scrollLeft=0
       — it doesn't know the chip is already fully visible inside this
       padding-left, so it scrolls exactly var(--pad-x) worth of distance
       to push it flush, even when the active chip is already first and
       nothing should move. That had two visible effects: the active
       first chip ended up misaligned from the H1's left edge (padding
       eaten away) and the left fade mask lit up at rest (scrollLeft>0
       even though there's nothing scrolled behind it). scroll-padding
       tells scrollIntoView to treat this padding as already "in view",
       so a chip sitting inside it needs no scroll. */
    scroll-padding-inline: var(--pad-x) 4px;
  }
  .hub-chips::-webkit-scrollbar { display: none; }
  .hub-chip { scroll-snap-align: start; }
  /* Right-edge fade — toggled by initChipRowFog() (script.js) based on
     actual scroll position, not just always-on; also gets a left one
     for free (same generic mechanism), useful once the active chip
     isn't first (see hubInitActiveChipScroll in assets/hub.js). 24px
     per explicit spec. Background is a flat solid-to-transparent
     match of this section's own light background (#d9d9d9 family —
     same value .reviews-main__grid-fade already uses for the same
     "fade to this page's background" purpose), not a guess. */
  .chip-row__fog {
    position: absolute;
    top: 0;
    bottom: 0;
    width: 24px;
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.2s ease;
    z-index: 1;
  }
  .chip-row__fog--left {
    left: 0;
    background: linear-gradient(to left, rgba(217, 217, 217, 0), #d9d9d9);
  }
  .chip-row__fog--right {
    right: 0;
    background: linear-gradient(to right, rgba(217, 217, 217, 0), #d9d9d9);
  }
  .chip-row__fog.is-visible { opacity: 1; }
  /* Compact 44px chips (2026-10-14 cases/reviews mobile decision) — was
     the desktop 50px/128px-min-width sizing unscoped on mobile, which
     left only 3 rows' worth of room for all 5 chips at 390px (measured
     live before this fix: 3 distinct rows). Dropping the fixed
     min-width (chips size to their own content instead) and trimming
     padding/gap fits all 5 into 2 rows, confirmed live after. Height
     exactly 44px — the touch tap-target floor, not just "smaller".
     Still true in the new single-row scroller — height unchanged, only
     the row's own wrap/overflow behavior changed above. */
  .hub-chip {
    height: 44px;
    min-width: 0;
    padding: 0 14px;
    gap: 6px;
    font-size: 14px;
    flex-shrink: 0;
  }
  .hub-chip .hub-icon { width: 17px; height: 17px; }
  /* Desktop segmented toggle hidden on mobile; the new icon button +
     dropdown (base rule above) takes over. */
  .hub-sort { display: none; }
  .hub-sort-mobile {
    display: flex;
    flex-shrink: 0;
    /* Own right inset — .hub-controls-wrap dropped its padding above
       so the chip scroller could bleed to the real screen edge; this
       restores a normal, comfortably-tappable margin for the button
       specifically, independent of that. */
    margin-right: var(--pad-x);
  }

  /* Service tags — 28px, compact (2026-10-14 cases/reviews mobile
     decision) — was the desktop ~33px pill sitting unscoped on mobile
     too, oversized next to the now-compact 44px chips/sort above it. */
  .hub-tag {
    height: 28px;
    padding: 0 10px;
    gap: 5px;
    font-size: 12px;
  }
  .hub-tag .hub-icon { width: 13px; height: 13px; }

  /* ---- Card grid ----
     Single column — multi-column grids stack, per spec. max-height
     removed (was a desktop-tuned pixel budget matching exactly ONE row
     of 3 side-by-side cards, i.e. visually a no-op there — see its own
     desktop comment; on a now-single-column stack the same fixed number
     would genuinely clip into card #2, which the "nothing clipped"
     rule forbids) — the fade is dropped alongside it, since there's
     nothing left for it to hint is hidden. The "load more" interaction
     itself is untouched: .cases-grid-card--extra's display:none/flex
     toggle is pure class logic, independent of any of this, so the
     first 3 cards stay immediately, fully visible and "load more"
     still reveals the remaining 6 — exactly the same information
     reachability as desktop, just without the extra height-clip layer
     that no longer serves its original purpose once cards stack. */
  .cases-main__grid-wrap,
  .reviews-main__grid-wrap {
    width: 100%;
    max-height: none;
    overflow: visible;
  }
  .cases-main__grid-fade,
  .reviews-main__grid-fade { display: none; }

  .cases-main__grid,
  .reviews-main__grid {
    grid-template-columns: 1fr;
    width: 100%;
    gap: 16px;
  }
  /* .review-card's own height:554px (set specifically for 3-up grid
     context, see its own desktop rule) would otherwise win over
     .review-card's base mobile rule (clamp(250px,40vh,300px), same
     specificity, but this later/more-specific-context selector) —
     reset here so reviews.html's own grid cards fall back to that
     already-correct, already-mobile-adapted sizing instead. (Inert on
     cases.html — .cases-grid-card is a different component with no
     such override to begin with — kept here anyway since it's a single
     shared mobile block covering both pages' grids.) */
  /* width:100% too — the carousel's own mobile rule above
     (min(78vw,300px), sized for a peeking-card layout) is unscoped and
     would otherwise leave the grid's single-column cards stuck at
     ~300px instead of filling their full-width 1fr column. */
  .reviews-main__grid .review-card { height: auto; width: 100%; }
  /* Top padding = side padding, explicit spec, 2026-10-15 bug report —
     was silently inheriting the carousel's unscoped 14px/16px/14px
     mobile rule (close to equal by coincidence, but not the same
     token/value, and not the actual cause of the real bug here, which
     was the ~80px empty gap above the avatar row from
     .review-card__badge-space/__photo-area, both removed from this
     page's own card template now — see reviews.html's buildReviewCard). */
  .reviews-main__grid .review-card { padding: var(--pad-x); }
  /* The carousel's own mobile quote rule just above (13px, "fit takes
     priority" inside a fixed clamp(250px,40vh,300px) card) would
     otherwise leak into the grid too — it's unscoped. Not appropriate
     here: the grid card is auto-height on mobile (no fixed box to fit
     into), so nothing forces a dip under the 14px floor. */
  .reviews-main__grid .review-card__quote-wrap { overflow: visible; flex: none; }
  .reviews-main__grid .review-card__quote { font-size: 15px; line-height: 1.3; }

  /* ---- Case card component ----
     width/height both fluid now (was a fixed 500x614px card) — every
     child below that assumed that fixed box (nowrap+ellipsis title,
     line-clamped quote, fixed-px separator/quote/image widths, a
     flex:1-grow body meant to fill a fixed-height parent) gets its own
     matching reset so nothing clips or overflows the new fluid width,
     and nothing relies on flex-grow filling space that no longer
     exists in a content-sized card. */
  .cases-grid-card {
    width: 100%;
    height: auto;
    padding: 20px var(--pad-x);
  }
  .cases-grid-card__tags {
    flex-wrap: wrap;
    overflow: visible;
    row-gap: 7px;
  }
  .cases-grid-card__content { gap: 10px; }
  .cases-grid-card__title { font-size: 18px; }
  .cases-grid-card__footer { padding: 0; }
  /* Same gap, same fix, different button — "Переглянути більше" was also
     bare text (~23px natural height from its own font-size/line-height,
     no box of its own) with no real tap target. Centred since it's the
     sole control at the bottom of a single column now, not sharing a
     row with anything else to align against. */
  .reviews-main__load-more {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 44px;
    width: 100%;
  }
}

/* ===================== CONTACTS PAGE (contacts.html) =====================
   "Email as hero" — a dark, full-bleed section matching .hero/.stats/
   .team/.cases-drum (lives OUTSIDE .light-wrapper, same as those; see
   that class's own comment above — it's the LIGHT rounded-stage
   wrapper, which this section deliberately isn't). Two columns at
   >=1024px: left = the email (the actual "hero"), right = an info
   column of rows (phone/address/hours/socials). 1024px specifically
   because the right column needs real room for its own label/value
   rows without cramping — narrower than that and a second column reads
   as squeezed, not "two columns"; see .contacts-hero__inner below.
   Single stacked column under 1024px, same rows in the same order.

   Contrast, checked against this section's own background
   (--color-bg-dark #060c0c, luminance ≈0.0033):
     #ffffff (email, row values)    → ratio ≈19.7:1
     --color-text-light #b5b5b5     → ratio ≈9.6:1
     #8d8d8d (eyebrow + every row label, same token cases-drum__label
       already uses on this exact background) → ratio ≈5.9:1
   All comfortably clear WCAG AA's 4.5:1 for normal text. */
.contacts-hero {
  background: var(--color-bg-dark);
  padding: 133px var(--pad-x) 100px;
  /* Same navbar-clearance value faq.html/cookies.html apply via
     .light-wrapper's own margin-top — this section plays that role
     itself here since it's the first thing in <main>, not nested
     inside that wrapper. */
}
.contacts-hero__inner {
  max-width: var(--max-width);
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  gap: 20px;
}
@media (min-width: 1024px) {
  .contacts-hero__inner {
    flex-direction: row;
    align-items: flex-start; /* top-align both columns — see comment above */
    gap: 100px;
  }
  .contacts-hero__main { flex: 1 1 56%; min-width: 0; }
  .contacts-hero__info { flex: 1 1 36%; min-width: 0; margin-top: 0; border-top: none; padding-top: 0; max-width: none; }
}

.contacts-hero__main {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
}

.contacts-hero__eyebrow {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 16px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: #8d8d8d;
}

.contacts-hero__email-wrap {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  width: 100%;
  margin-top: 20px;
}
/* font-size is set by this page's own fitEmailHeadline() JS, which
   measures the real text against the actual available width — this is
   only a pre-JS/no-JS fallback. #contactsEmailLine2 flows inline right
   after line1 by default (both spans together read as the one literal
   email string, no break); .is-split (JS, when the full address
   wouldn't fit on one line at the site's own H1 size) makes it a block
   instead, forcing exactly a two-line split right after "@" — never
   mid-word, per explicit requirement. */
.contacts-hero__email {
  display: inline-block;
  max-width: 100%;
  /* var(--font-body), not --font-display: Neutral Face (the display
     font used everywhere else, e.g. "ЗАТЯГУЄ"/"ГОТОВІ ЗАТЯГНУТИСЬ?") has
     no real lowercase letterforms — lowercase input still rendered as
     visual caps, which silently failed the "lowercase, no text-transform,
     emails are read as written" requirement despite text-transform
     genuinely being none and the DOM text genuinely being lowercase.
     Mazzard H Bold has real lowercase glyphs. */
  font-family: var(--font-body);
  font-weight: 700;
  font-size: clamp(28px, 7vw, 64px);
  line-height: 1.08;
  color: #fff;
  text-align: left;
  text-transform: none; /* emails are read as written, never uppercased */
  white-space: normal;
  overflow-wrap: normal;
  word-break: keep-all; /* belt-and-braces alongside the JS split — never break mid-word even before JS runs */
  transition: color 0.15s ease;
}
.contacts-hero__email.is-split #contactsEmailLine2 { display: block; }
.contacts-hero__email:hover,
.contacts-hero__email:focus-visible { color: var(--color-accent); }
.contacts-hero__email:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 6px;
}
/* Hover affordance — pure CSS now (the headline is a plain mailto:
   link, no JS state to drive this): reveals on hover OR keyboard focus
   of the link itself. :focus-within on the wrap (not :focus-visible on
   the link) so it also works the same via focus, consistent with the
   hover trigger. */
.contacts-hero__underline {
  width: 64px;
  height: 3px;
  margin-top: 10px;
  background: var(--color-accent);
  transform: scaleX(0);
  transform-origin: left;
}
@media (prefers-reduced-motion: no-preference) {
  .contacts-hero__underline { transition: transform 0.3s ease; }
}
.contacts-hero__email-wrap:hover .contacts-hero__underline,
.contacts-hero__email-wrap:focus-within .contacts-hero__underline { transform: scaleX(1); }

/* Row holding just the small copy button + its "Скопійовано" feedback,
   right under the email headline. */
.contacts-hero__email-row {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
  min-height: 28px;
  margin-top: 18px;
}

/* ── Shared copy-button + feedback-note classes — used by the email
   row above AND each phone/etc. row built in contacts.html's own
   <script>, so the interaction looks and behaves identically wherever
   it appears. ── */
.contacts-copy-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  margin: -10px; /* visual size stays modest; tap target is the full 44px */
  color: var(--color-text-light);
  cursor: pointer;
  transition: color 0.15s ease;
}
.contacts-copy-btn:hover,
.contacts-copy-btn:focus-visible { color: var(--color-accent); }
.contacts-copy-btn:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}
.contacts-copied-note {
  font-family: var(--font-body);
  font-size: 14px;
  font-weight: 700;
  color: var(--color-accent);
}

/* ── Right column: info rows (Телефон, Адреса, Графік, Соцмережі) ──
   Built entirely by this page's <script>, see contacts.html. Stacked
   below the left column under 1024px (own top divider, like the old
   single-column .contacts-hero__meta did); a true second column at
   >=1024px (divider/margin removed there — see the min-width:1024px
   block above, column rules live together with the layout switch). */
.contacts-hero__info {
  margin-top: 28px;
  padding-top: 28px;
  border-top: 1px solid rgba(255, 255, 255, 0.1);
  width: 100%;
  max-width: 560px;
  display: flex;
  flex-direction: column;
}
.contacts-info-row {
  padding: 20px 0;
  border-top: 1px solid rgba(255, 255, 255, 0.1);
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.contacts-info-row:first-child { border-top: none; padding-top: 0; }
.contacts-info-row:last-child { padding-bottom: 0; }
.contacts-info-row__label {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 16px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: #8d8d8d;
}
.contacts-info-row__value-wrap {
  display: flex;
  align-items: center;
  gap: 18px;
  flex-wrap: wrap;
}
.contacts-info-row__value {
  margin: 0;
  font-family: var(--font-body);
  font-size: 17px;
  font-weight: 500;
  color: #fff;
}
a.contacts-info-row__value--link { transition: color 0.15s ease; }
a.contacts-info-row__value--link:hover,
a.contacts-info-row__value--link:focus-visible { color: var(--color-accent); }

/* Соцмережі row — icon BUTTONS (not plain link-icons), same ghost-
   button visual language as .contacts-hero__map-btn just below (same
   border/hover-tint tokens), sized up to a real 48px since these are
   the primary way to reach each network, not a secondary action. */
.contacts-info-row__socials {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
}
.contacts-social-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 48px;
  height: 48px;
  border: 1px solid rgba(255, 255, 255, 0.25);
  border-radius: var(--radius-btn);
  color: #fff;
  transition: background 0.15s ease, border-color 0.15s ease;
}
.contacts-social-btn svg { display: block; }
.contacts-social-btn:hover,
.contacts-social-btn:focus-visible {
  background: var(--color-accent);
  border-color: var(--color-accent);
}
.contacts-social-btn:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}

.contacts-hero__map-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: 0 20px;
  border: 1px solid rgba(255, 255, 255, 0.25);
  border-radius: var(--radius-btn);
  font-family: var(--font-body);
  font-size: 14px;
  font-weight: 600;
  color: #fff;
  cursor: pointer;
  transition: background 0.15s ease, border-color 0.15s ease;
}
.contacts-hero__map-btn:hover,
.contacts-hero__map-btn:focus-visible {
  border-color: var(--color-accent);
  background: rgba(255, 40, 40, 0.1);
}
.contacts-hero__map-btn:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}

@media (max-width: 768px) {
  /* Same 133px navbar-clearance value faq.html/cookies.html use for
     .light-wrapper (top:23px + height:70px desktop... but the mobile
     navbar is actually top:66px/height:60px, bottom edge 126px — 133px
     still clears it, just with a tighter 7px gap than desktop's 40px;
     matching the SAME value those pages already use, rather than
     deriving a separate mobile-specific number, is the point — this
     section used to use 110px here, which sat the eyebrow UNDER the
     fixed header (110 < 126). Superseded for the title specifically by
     the <1024px block below (178px, faq.html's own measured value —
     see that block's own comment); this padding-bottom/sides value
     still applies at every width <1024px either way, nothing else here
     touches padding-top again. */
  .contacts-hero { padding: 133px var(--pad-x) 72px; }
  .contacts-hero__inner { gap: 16px; }

  /* 16+16 = 32px — the SAME rhythm as the gap between two consecutive
     .contacts-info-row's (16px padding-bottom + 16px padding-top each,
     below). Was 20+20=40px, a visibly bigger gap than the one between
     the info rows themselves — explicit "match the spacing" request. */
  .contacts-hero__info { margin-top: 16px; padding-top: 16px; max-width: none; }
  .contacts-info-row { padding: 16px 0; }
  .contacts-info-row__value { font-size: 16px; }
  .contacts-hero__map-btn { width: 100%; }
  .contacts-info-row__value-wrap { gap: 12px; }
}

/* ===================== CONTACTS MOBILE <1024px — title + email row =====================
   Placed AFTER the <=768px block above on purpose: its padding-top
   rule needs to win over that block's own `padding: 133px ...` shorthand
   for the overlapping <=768px range too (same specificity — later wins).
   "КОНТАКТИ" becomes the real page title here (was a small grey eyebrow,
   desktop-only role now — see .contacts-hero__eyebrow's base rule,
   untouched); the email headline + its copy-pill row are hidden
   entirely, replaced by a plain "Email" info row built in <script>
   (buildEmailRow(), inserted/removed from the DOM responsively by
   syncEmailRowForViewport() — not just CSS-hidden, so it never affects
   :first-child border logic on the OTHER rows at any width). */
@media (max-width: 1023.98px) {
  /* Display treatment measured LIVE from faq.html's own mobile page
     title (.faq-main__subtitle) via Playwright — font-display 700,
     22px, uppercase, 1.05px letter-spacing, 1.34 line-height — not
     eyeballed. Color is white here (that page's own #445156 would be
     unreadable on this section's dark background); everything else
     matches exactly. */
  .contacts-hero__eyebrow {
    font-size: 22px;
    letter-spacing: 1.05px;
    line-height: 1.34;
    color: #fff;
  }
  /* 158px = 126 (fixed header's own bottom edge, mobile) + 32 (explicit
     header-to-title gap). padding-top sets this section's own absolute
     top offset, not a gap relative to the header directly — the header
     itself occupies the first 126px, so that has to be added back in
     to land on an actual 32px GAP rather than a 32px absolute position
     (which would sit the title under the header). Verified via
     getBoundingClientRect in Playwright WebKit at 320/390/430: gap is
     32px at every width. Overrides just the top side of the padding
     shorthand above (sides/bottom still apply from that rule). */
  .contacts-hero { padding-top: 158px; }

  .contacts-hero__email-wrap,
  .contacts-hero__email-row { display: none; }
}

/* ===================== TOUCH TAP-TARGET FLOOR =====================
   Final bug-hunt, decision #2 (2026-10-14): every interactive control
   gets a real ≥44×44 tap area on touch/narrow — `(max-width:1023px),
   (pointer:coarse)`, not the site's usual 768px layout breakpoint,
   specifically because a touch-capable landscape phone (844px wide) or
   a small Android (412px, Pixel-7-class) sits ABOVE 768px and was
   rendering these controls at full desktop size with no floor at all —
   confirmed live in the final bug-hunt matrix (e.g. .footer__social-link
   already had a correct 44×44 override inside the OLD @media(max-width:
   768px) block, but fell back to a bare 27×27 at 844px). Desktop/mouse
   (pointer:fine, ≥1024px) is untouched by construction — neither half
   of this query ever matches there, so these rules simply don't apply
   and nothing here can affect desktop pixel diff.

   Two techniques, picked per component the way the brief asked:
   - min-height/min-width (+flex centering where the box was plain
     unpadded text) for anything shaped like a real button, where a
     physically bigger tap area IS the expected touch affordance — most
     of these already had a correct real-size bump inside the old
     768px-only block; this just extends the SAME treatment to the
     769–1023px / coarse-pointer gap that block never covered.
   - an invisible ::after hit area, centered and sized up to 44px
     independent of the element's own box, for anything whose visual
     size has to stay as-is: a brand mark, a tiny carousel dot/arrow, or
     a link sitting inline inside a sentence of running prose (growing
     those for real would visibly disrupt the paragraph/row they live
     in, not just make them easier to tap). */
@media (max-width: 1023px), (pointer: coarse) {
  .hero__cta-primary,
  .hero__cta-secondary,
  .cta-banner__btn,
  .faq-filter__btn,
  .reviews__cta,
  .navbar__nav a,
  .reviews-main__load-more,
  .reviews-main__grid .review-card__readmore-btn {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }
  /* Short labels ("FAQ", "ПРО НАС") — the only ones in this group
     narrow enough that min-height alone leaves width under 44px too. */
  .navbar__nav a { min-width: 44px; }
  /* Already a real box with padding on every axis (a pill button) —
     centering is already correct, this only raises the floor. */
  .review-card__video-btn { min-height: 44px; }
  /* Already exactly 44×44 inside the old <768px block — this is pure
     range-extension, same values, now also covering 769–1023px/coarse. */
  .footer__social-link { min-width: 44px; min-height: 44px; }

  /* Invisible hit-area — visual size stays exactly as designed. */
  .navbar__logo,
  .reviews__dot,
  .reviews__arrow--next,
  a.contacts-info-row__value--link,
  .faq__ask-btn,
  .faq-main__ask-btn,
  .footer__nav a {
    position: relative;
  }
  .navbar__logo::after,
  .reviews__dot::after,
  .reviews__arrow--next::after,
  a.contacts-info-row__value--link::after,
  .faq__ask-btn::after,
  .faq-main__ask-btn::after,
  .footer__nav a::after {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    width: max(44px, 100%);
    height: max(44px, 100%);
    transform: translate(-50%, -50%);
  }
}
