/* ═══════════════════════════════════════════════════════════════════════════════════════════
   hetoimos — styles.css

   The study (docs/REFERENCES.md, ten sites measured 2026-08-07) produced four numbers, and this
   file is built to hit them rather than to a taste:

     · under 200 words above the fold, under 400 on the landing — the tier's median is 110 DOM
       words and the old hetoimos site carried 1 463
     · a near-black ground — seven of the nine grounds measured were black or near-black, and the
       two light ones were the two with no 3D on them
     · a GROTESQUE display face, never humanist — Aeonik, Neue Montreal, Mona Sans, URW DIN, Fort
       across the set, and not one geometric-humanist face. Manrope, which the old site ships, is
       exactly that, and it is a large part of why that page reads as a competent template
     · one object, full bleed, and the page composed around it

   ── THE HEADLINE FACE IS UNBOUNDED; THE BODY IS STILL A SYSTEM STACK ─────────────────────────
   The paragraph that stood here explained why NO webfont was loaded — a real budget argument, and it
   was answered by the thing it could not weigh. Ihor, 2026-08-08: «шрифт поменять для заголовков,
   плохо бросается в глаза. Возьми Vilicor'овский.» A system stack cannot be distinctive by
   definition: it resolves to whatever grotesque the reader's OS ships, which is the same face they
   see in every other window. That is exactly right for body text and exactly wrong for the one line
   the page is trying to be remembered by.

   Unbounded is the display face Vilicor already ships (siaivo/apps/web — Manrope / Unbounded /
   JetBrains Mono), so the two properties now share a headline voice, and it is SIL Open Font
   License 1.1 — fonts/OFL.txt travels with it.

   THE COST IS PAID WHERE IT SHOWS: 187 kB, on --display only. The body keeps the system stack, so
   the reader's first paragraph still renders at zero bytes and `font-display: swap` means the
   headline is readable in the system face before Unbounded lands rather than invisible.
   ⚠ AND CHANGING THE FACE MOVES EVERY MEASURED WRAP IN THIS FILE. `.hero h1 { max-width: 10.2em }`
   and `.hero-page h1 { max-width: 18ch }` were both derived from the OLD face's metrics, and their
   comments state the widths they were measured against. Re-measure before trusting either.
   ═══════════════════════════════════════════════════════════════════════════════════════════ */

/* ⚠ THE PATH IS RELATIVE TO THIS STYLESHEET, NOT TO THE DOCUMENT — which is what makes one line work
   for every page. The subpages live a directory down (/tsiny/, /roboty/ …) and load this same file,
   and a url() inside CSS resolves against the CSS, so `./fonts/…` is the site root from all of them.
   A root-relative `/fonts/…` would look equivalent and break the dev server, which serves the site
   from /sites/03-hetoimos/ rather than from /. */
@font-face {
  font-family: 'Unbounded';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url('./fonts/unbounded-cyrl-latin.woff2') format('woff2');
}

:root {
  --void: #07090C;
  --ground: #0B0E13;
  --ink: #F2EEE4;
  --ink-2: rgba(242, 238, 228, .74);
  --ink-3: rgba(242, 238, 228, .52);
  /* ⚠ --ink-4 WAS USED FIVE TIMES AND DEFINED NOWHERE, AND THAT IS SILENT, NOT AN ERROR.
     .foot-tag-2, .foot-col h4, .foot-note, .foot-bot and .foot-c all asked for it. An undefined
     custom property makes the declaration INVALID AT COMPUTED-VALUE TIME, which for an inherited
     property means `inherit` — NOT a fallback down the cascade — so all five inherited --ink-3
     from `.foot` and measured rgba(242,238,228,0.52), the same value as the .foot-tag line above
     them and the .foot-col links below them. The footer is authored in three tones and shipped in
     two. .38 is the step the ladder already implies: 1.0 / .74 / .52, ratio ~.70, so .52 x .703.
     ⚠ DO NOT "SIMPLIFY" BY DELETING THE FIVE var(--ink-4) DECLARATIONS INSTEAD. That is not
     equivalent: `.foot-col h4 { color: var(--ink-4) }` currently resolves to the inherited --ink-3,
     but with the declaration gone h4 falls to `h1,h2,h3,h4 { color: var(--ink) }` and the column
     headings jump to FULL brightness. */
  --ink-4: rgba(242, 238, 228, .38);
  --hair: rgba(242, 238, 228, .13);
  --hair-2: rgba(242, 238, 228, .26);
  --beige: #D8CDAF;

  --display: Unbounded, "Segoe UI Variable Display", "SF Pro Display", Inter, "Helvetica Neue", Arial, sans-serif;
  --text: "Segoe UI Variable Text", "SF Pro Text", Inter, "Helvetica Neue", Arial, sans-serif;
  --mono: "Cascadia Mono", "SF Mono", "JetBrains Mono", ui-monospace, "Consolas", monospace;

  /* ⚠ THE GREEK HAS ITS OWN STACK, AND THE REASON IS ONE CODEPOINT.
     The word was set as ἕτοιμος — POLYTONIC, with U+1F15, epsilon with dasia and
     oxia. Almost no interface font covers Polytonic Greek, so that one letter fell through to
     whatever fallback the system had, at a different size, a different weight and usually a serif.
     Photographed across Bahnschrift, Franklin Gothic, Segoe UI and Arial: the epsilon is a foreign
     letterform in ALL FOUR. That is what "иголистый шрифт, что за помойка" was — not the stack, one
     character.
     The word is now set in CAPS, ΕΤΟΙΜΟΣ, which carries no diacritic at all, so there is
     nothing that can fall back. It also reads better at the size this thing is drawn at. The stack
     is grotesques that all cover Greek capitals; Bahnschrift is the DIN-like one and is first
     because the study found URW DIN and Neue Montreal in this tier and nothing humanist. */
  --display-gr: Bahnschrift, "Franklin Gothic Medium", "Helvetica Neue", "Segoe UI", Arial, sans-serif;

  /* ⚠ RE-DERIVED FOR UNBOUNDED, WHICH IS A MUCH WIDER FACE THAN THE SYSTEM STACK IT REPLACED.
     At the old 2.15rem/5.4vw/4.1rem the headline broke «Сайт для / вашого / бізнесу за / три дні» at
     every width from 360 to 1440 — a one-word orphan, the exact failure this file already documents
     for `.hero-page h1`. The letters did not change size; the FACE did, and 10.2em of column is a
     different number of characters in each one.
     1.85/4.4vw/3.4 is what fits the authored two lines. Unbounded at 29.6 px reads heavier than the
     system grotesque did at 34.4, so the headline gained presence while losing height — which is
     what «плохо бросается в глаза» was asking for. */
  --fs-h1: clamp(2.3rem, 5.2vw, 4.2rem);
  --fs-h2: clamp(1.7rem, 4.2vw, 3.1rem);
  --fs-h3: clamp(1.05rem, 1.6vw, 1.25rem);
  --fs-lede: clamp(1.02rem, 1.45vw, 1.3rem);
  --fs-body: clamp(0.96rem, 1.1vw, 1.06rem);
  --fs-xs: 0.72rem;

  --gutter: clamp(1.15rem, 5vw, 5rem);
  --bar-h: 4.25rem;
  --sec-y: clamp(5.5rem, 14vh, 11rem);
  --measure: 34ch;

  color-scheme: dark;
}

/* ⚠ THE PAGE OPTS INTO viewport-fit=cover (index.html:5) AND THEN ANSWERED ONLY ONE INSET.
   On a notched iPhone in LANDSCAPE, Safari lays a cover page edge to edge, so the layout viewport
   runs under the sensor housing and under both rounded corners. Measured 2026-08-07 with
   Emulation.setSafeAreaInsetsOverride (verified live: a probe element read 59px and .foot's
   padding-bottom moved 40 -> 61px, so the insets were really applied) at 844x390 inset 59:
     .bar-mk   left 25.6   — 33.4 px INSIDE the 59 px left inset
     .hero h1  left 42.2   — 16.8 px inside it
     .bar-cta  right 818.4 — 33.4 px past the safe right boundary of 785
   Nothing on the first screen moved. Only styles.css:395 (.foot, bottom only) answered anything.

   max(), NOT a bare env(): where the inset is 0 — every portrait phone, every desktop — max()
   yields the existing gutter and NOTHING moves (verified at 8 inset-0 viewports, rects identical).
   The `, 0px` fallback is load-bearing: env() with no fallback in a browser that does not know the
   keyword is invalid at computed-value time, and through a custom property that collapses the
   padding to 0 rather than degrading to the gutter.
   NOT COVERED, deliberately: the portrait TOP inset. The bar is height:var(--bar-h) at top:0 and
   the hero is calc(100svh - var(--bar-h)), so answering the top inset means changing the bar's
   height and the hero's minimum together — a layout change, not a padding change. Measured at
   390x844 with a 59 px top inset the bar content still sits at y=9.71. Its own item. */
:root {
  --safe-l: env(safe-area-inset-left, 0px);
  --safe-r: env(safe-area-inset-right, 0px);
}

* { box-sizing: border-box; }

html {
  /* ⚠ THE GROUND IS DECLARED HERE AND NOWHERE ELSE. The canvas clears to the same value, so a
     machine with no WebGL2 gets a page that is dark and complete rather than a hole where a render
     should be — src/main.js adds `no-gl` and stops, and nothing below cares. */
  background: var(--void);
  /* The native smooth scroll is turned OFF once src/smooth.js is running: two easing systems on the
     same input fight each other, and the result overshoots — measured, a wheel that landed the real
     position at 584 px had the content still travelling toward 948. The module handles anchors
     itself. */
  scroll-behavior: smooth;
  -webkit-text-size-adjust: 100%;
}
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }
html.smooth { scroll-behavior: auto; }

body {
  margin: 0;
  background: transparent;
  color: var(--ink-2);
  font-family: var(--text);
  font-size: var(--fs-body);
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  overflow-x: clip;
}

/* ⚠ h4 IS IN THIS RESET AND IT WAS NOT, so every .foot-col heading carried the user agent's own
   `margin: 1.33em 0` on top of its .35rem bottom. Measured at 390x844: computed margin
   `15.3216px 0 5.6px 0` on `.foot-col h4`, and the distance from .foot-brand's bottom to the
   «СТОРІНКА» heading was 44.12 px where .foot-top declares gap: clamp(1.8rem,4vw,3rem) = 28.80.
   28.80 + 15.32. Repeated above «ДОКУМЕНТИ» and «КОНТАКТИ» — three column separations 53% larger
   than the one number the stylesheet states for them. Safe because .foot-col h4 immediately re-sets
   font-family, size, colour and weight, so only the margin changes. */
h1, h2, h3, h4 { margin: 0; font-family: var(--display); color: var(--ink); font-weight: 500; }
/* ⚠ `text-wrap: balance` ON h1 AND h2 ONLY — NOT ON THE RESET LINE ABOVE, WHICH WOULD PULL IN h3.
   Ukrainian display headings orphan their last word on a phone. Measured at 360-430 px:
   `#how h2` shipped ["Три кроки. Жодного","дзвінка."], `#ref h2` ["Знаєте когось, кому потрібен",
   "сайт?"], `#can h2` ["Сайт — найпростіше з того, що","ми робимо."] — 29 characters on the long
   line. Balanced they become 2 even lines with no orphan, and no case measured gained a line or
   changed height at any width.
   ⚠ NOT h3: on h3 it puts an em dash at the start of a line («Подобається» / «— запускаємо») and
   gives the #can cards a one-word first line («Складні» / «розрахунки на сайті»). */
h1 { font-size: var(--fs-h1); line-height: 1.02; letter-spacing: -.035em; text-wrap: balance; }
h2 { font-size: var(--fs-h2); line-height: 1.08; letter-spacing: -.028em; text-wrap: balance; }
h3 { font-size: var(--fs-h3); line-height: 1.25; letter-spacing: -.012em; }
p  { margin: 0; }
ul, ol { margin: 0; padding: 0; list-style: none; }
a { color: inherit; }

/* The render, behind everything, out of every hit test. */
#stage {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  z-index: -1;
  pointer-events: none;
}
.no-gl #stage { display: none; }

/* ── the bar ───────────────────────────────────────────────────────────────────────────────
   Two elements: the lockup and one action. The study is unambiguous about this — Lando Norris,
   Awwwards Site of the Year, puts a wordmark top-left and a single accent CTA top-right and
   nothing else in the corner. Everything the old site kept up there (six section links, a
   language switch, a background picker) is chrome a prospect has to read past. */
.bar {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 10;
  height: var(--bar-h);
  padding: 0 max(var(--gutter), var(--safe-r)) 0 max(var(--gutter), var(--safe-l));
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  /* Glass, and only here. backdrop-filter blurs everything under an element every frame it is on
     screen; over a canvas that is itself redrawing, one full-bleed blurred rectangle is the whole
     screen composited twice. The bar is 68px tall, so it is affordable and it is the only place. */
  /* ⚠ NO backdrop-filter ON THE BAR. It is the one element on screen 100% of the time, and a
     backdrop filter re-blurs everything under it on EVERY FRAME — over a canvas that is itself
     redrawing, that is the whole viewport width composited twice, forever, for a 60px strip. The
     buttons keep theirs because they are small and because a control is where the material reads;
     the bar gets a flat translucent fill, which at this opacity over a near-black field is
     visually the same thing and costs one paint. */
  background: rgba(7, 9, 12, .72);
  border-bottom: 1px solid rgba(255, 255, 255, .09);
  box-shadow: inset 0 -1px 0 0 rgba(0, 0, 0, .3);
}

/* ⚠ A FADE BELOW THE BAR, BECAUSE 72% OPAQUE IS NOT OPAQUE. Photographed on a 390 px phone: the
   heading «Не макети.» scrolled under the lockup and stayed plainly legible through the bar, so the
   mark, the button and a headline were all readable in the same 68 px of screen. It reads as a
   layout fault rather than as glass.
   The fix is a short gradient UNDER the bar rather than more opacity, so type dissolves on approach
   instead of being cut by a hard edge. It is a plain background-image on a pseudo-element: no
   filter, no compositing cost, one paint — the note above about never putting backdrop-filter here
   still holds and this is what replaces it. `pointer-events: none` so it cannot eat a tap. */
.bar::after {
  content: "";
  position: absolute;
  left: 0; right: 0; top: 100%;
  height: 30px;
  pointer-events: none;
  background: linear-gradient(to bottom, rgba(7, 9, 12, .62), rgba(7, 9, 12, 0));
}
/* On a phone the bar is a larger share of the screen and there is no room to scroll away from it, so
   it commits: nearly opaque, and the fade doubles. */
@media (max-width: 859px) {
  .bar { background: rgba(7, 9, 12, .93); }
  .bar::after { height: 44px; background: linear-gradient(to bottom, rgba(7, 9, 12, .8), rgba(7, 9, 12, 0)); }
}
.bar-mk { display: flex; align-items: baseline; gap: .6rem; text-decoration: none; }
.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
.bar-gr { height: 1.35rem; width: auto; fill: var(--beige); display: block; }
/* THE WORDMARK BESIDE THE DRAWN MARK. At .34em of tracking in --ink-3 it read as a scatter of
   characters rather than as a name — «почему-то хетоимос хуйня справа от логотипа на десктопе» —
   because 0.34em between letters at 12 px is wider than most of the letters themselves, and --ink-3
   is the dimmest ink on the site. At .16em it still reads as a lockup and also reads as a WORD. */
.bar-la {
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: .16em;
  text-indent: .16em;
  text-transform: lowercase;
  color: var(--ink-2);
}
.bar-cta {
  font-size: .84rem;
  letter-spacing: .01em;
  text-decoration: none;
  color: var(--ink);
  padding: .56rem 1rem;
  border: 1px solid rgba(255, 255, 255, .12);
  border-radius: 999px;
  background: rgba(255, 255, 255, .09);
  backdrop-filter: saturate(1.8) blur(24px);
  -webkit-backdrop-filter: saturate(1.8) blur(24px);
  box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, .2), inset 0 -1px 0 0 rgba(0, 0, 0, .3);
  transition: background .2s ease, border-color .2s ease;
  white-space: nowrap;
}
/* .bar-cta:hover lives in the @media (hover: hover) block at the foot of this file — see the note
   there. Its PRESS state is here, unconditional, because a press is real on both input kinds. */
.bar-cta:active { background: rgba(255, 255, 255, .20); border-color: rgba(255, 255, 255, .40); }

/* ── sections ─────────────────────────────────────────────────────────────────────────────
   A plain column. No pinning, no scroll hijack, no snap — which is what lets src/main.js measure
   the section tops ONCE on resize and never inside the frame. The deck model 01-gems uses is
   right for a seven-screen object study and wrong for a page that has to be scanned and bought
   from. */
main { padding-top: var(--bar-h); }
.s {
  min-height: 42svh;
  padding: var(--sec-y) max(var(--gutter), var(--safe-r)) var(--sec-y) max(var(--gutter), var(--safe-l));
  max-width: 78rem;
  margin: 0 auto;
}
/* ⚠ AN ORPHANED MARGIN FROM A HEADING THAT NO LONGER EXISTS.
   tools/build-het.mjs strips the eyebrow, and on the page's primary section the h2 and the lede
   too, out of every section it lifts from this landing — so the first surviving child of a lifted
   section is the grid, and .tiers/.works/.stack/.grid6/.care/.rows each carry a margin-top of
   2.2-2.6rem written to clear a heading that is no longer above it. It ADDS to this rule's
   padding. Measured at 390x844 before the fix: /tsiny/ hero copy ended y=408.47 and div.tiers
   started y=656.22 — 247.75 px of empty screen, 29.4% of the viewport, with nothing naming the
   price table underneath. Section-to-section gaps on that page ran 247.75 / 274.69 / 271.50 /
   236.30 / 166.73 — five values on one page against the landing's uniform 236.30. The internal
   control: #contact is the one inner-page section that keeps its h2 (build-het.mjs), and it was
   the one measuring 236.30.
   A no-op on the landing at every width — every section there starts with p.eyebrow, whose
   margin-top is already 0. Specificity (0,2,0), so it beats .doc h2 (0,1,1) from up here, which
   is what zeroes the 2.6rem above the first clause on /oferta/ and /pryvatnist/. */
.s > :first-child { margin-top: 0; }

.hero {
  min-height: calc(100svh - var(--bar-h));
  display: grid;
  align-content: center;
  position: relative;
}
/* The <br> in the markup is the intended break. A 30ch measure fought it and wrapped the
   headline to four lines, two of which went under the bar — the hero is exactly one screen tall,
   so a headline that grows sideways has nowhere to grow into. `min-content` on the heading lets
   the authored break stand and the lede keeps its own reading measure. */
.hero .copy { max-width: min(44rem, 100%); }
/* `ch` is the width of a ZERO in the current font, and the current font here is 5.6rem of display
   grotesque — so 15ch was 15 zeros wide at 90px, which the hero's own grid then had to fit beside
   the mark. It wrapped "Сайт вашого бізнесу." to three lines and the authored break made a fourth.
   An absolute measure is the honest unit when the type is this large: the headline gets a column,
   not a character count.
   ⚠ AND THE COLUMN IS IN `em`, NOT `rem`, BECAUSE THE TYPE SCALES AND 22rem DID NOT.
   `--fs-h1` is clamp(2.15rem, 5.4vw, 4.1rem), so the font grows with the viewport while a rem
   measure stays 352 px — the type outgrew its own column and the headline broke one word per
   line. Measured on `/`: 1440x900 gave FOUR lines, «Сайт» / «вашого» / «бізнесу.» / «За три
   дні.», h1 box 352x268 inside a 704 px .copy column at font-size 65.6 px; 1280x800 the same
   four; 744x1133 and every landscape phone gave a one-word orphan line. At 844 the first
   authored line alone needs 442.9 px and was given 352.
   10.2em is derived, not picked: 352 / 34.4 (the clamped phone font below ~637 px of width)
   = 10.23, so the measure is byte-identical on every portrait phone — 360/375/390/430 wrap to
   the same two lines, same words — and then grows with the type. 744 and the landscapes collapse
   to 2 clean lines; 1280 and 1440 go 4 lines -> 2. */
/* ⚠ 10.2em WAS DERIVED FROM THE OLD FACE AND IS WRONG FOR THIS ONE — see the note at --fs-h1.
   A column measured in `em` follows the type size but NOT the type's width, and Unbounded sets far
   fewer characters per em than the system grotesque did. 14em is the same authored two lines. */
.hero h1 { max-width: 14em; }
.lede {
  margin-top: 1.15rem;
  max-width: var(--measure);
  font-size: var(--fs-lede);
  color: var(--ink-2);
  /* The mark is behind the copy at full exposure on this one screen. A shadow tracks the glyph,
     where a plate would draw a rectangle over the only object on the page. */
  text-shadow: 0 1px 18px rgba(7, 9, 12, .92), 0 0 46px rgba(7, 9, 12, .6);
}
.hero h1 { text-shadow: 0 2px 26px rgba(7, 9, 12, .9), 0 0 60px rgba(7, 9, 12, .55); }

.acts { margin-top: 2rem; display: flex; flex-wrap: wrap; gap: .7rem; }
/* ── GLASS ────────────────────────────────────────────────────────────────────────────────
   «Кнопки тоже должны этим эффектом обладать.» What Apple's material is, taken apart, because
   every one of the four parts is doing work and leaving any of them out is what makes a glass
   button look like a grey rectangle:

     1. a HEAVY blur          28px, not 8. A light blur reads as a translucent fill; a heavy one
                              reads as thickness, because the eye stops being able to resolve what
                              is behind it and only sees that something is.
     2. a SATURATION LIFT     1.8. This is the part everybody omits. Blurring averages colour
                              toward grey, so a blurred pane over a coloured ground comes out
                              duller than the ground — pushing saturation back up is what keeps
                              the pane feeling like glass over colour rather than like fog.
     3. a BRIGHTNESS FLOOR    the ground here is near-black, so a pane with no fill of its own is
                              invisible. 9% white is the least that reads as a surface.
     4. TWO EDGES, not one    a bright inset line along the top and a dark one along the bottom.
                              That pair is what makes a flat rectangle read as a solid with a
                              thickness catching a light from above, and it costs one box-shadow.

   ⚠ AND IT IS SPENT ONLY ON CONTROLS. Each backdrop-filter element forces its own backdrop root
   and re-blurs everything under it every frame it is on screen, over a canvas that is already
   redrawing. A full-bleed blurred panel is the whole screen composited twice. Buttons and the bar,
   nothing else. */
.btn {
  display: inline-flex;
  align-items: center;
  min-height: 2.85rem;
  padding: 0 1.35rem;
  border-radius: 999px;
  border: 1px solid rgba(255, 255, 255, .12);
  background: rgba(255, 255, 255, .07);
  backdrop-filter: saturate(1.8) blur(28px);
  -webkit-backdrop-filter: saturate(1.8) blur(28px);
  color: var(--ink);
  text-decoration: none;
  font-size: .92rem;
  box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, .15),
              inset 0 -1px 0 0 rgba(0, 0, 0, .26),
              0 6px 20px -14px rgba(0, 0, 0, .7);
  transition: background .22s ease, border-color .22s ease, transform .22s var(--ease-out, cubic-bezier(.19,1,.22,1));
}
/* .btn:hover and .btn-q:hover are in the @media (hover: hover) block at the foot of this file.
   ⚠ THE PRESS WAS RUNNING BACKWARDS. `.btn:active { transform: translateY(0) }` was written to
   cancel the hover lift on a mouse — but on a touch screen the tap LATCHED :hover (measured on a
   real Input.dispatchTouchEvent tap at 390x844: the button held translateY(-1px) and 17% fill
   3.65 s after the finger left, and only a tap on some other element cleared it), so the resting
   state was RAISED and the press LOWERED it back to flat. A press should go DOWN from rest. */
.btn:active { background: rgba(255, 255, 255, .22); transform: translateY(1px); }
/* The secondary keeps the glass and loses the fill — a rule you can see round a word is a control,
   a filled pill is a panel. It still blurs, or the two buttons read as two design systems. */
.btn-q {
  background: rgba(255, 255, 255, .035);
  border-color: rgba(255, 255, 255, .11);
  color: var(--ink-2);
  box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, .10);
}

.cue {
  position: absolute;
  left: max(var(--gutter), var(--safe-l));
  bottom: 2rem;
  margin: 0;
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: .34em;
  /* ⚠ NO text-indent HERE. It put «Гортайте» at ink-left 23.42 px while the two buttons directly
     above it, and 64 other elements on the page, sit at 19.51. .34em of a .72rem mono is 3.92 px —
     too small to read as an indent, large enough to break the only vertical line the eye has on a
     390 px screen. The chevron below is a rotated square whose painted left tip is already at
     ~17.5 px, so the word now sits on the rail rather than over the chevron's centre.
     The trailing letter-space costs nothing: .cue is `justify-items: start` in a grid. */
  text-transform: uppercase;
  color: var(--ink-3);
  display: grid;
  gap: .55rem;
  justify-items: start;
}
.cue::after {
  content: "";
  width: .5rem; height: .5rem;
  border-right: 1.5px solid currentColor;
  border-bottom: 1.5px solid currentColor;
  rotate: 45deg;
  animation: fall 2.1s cubic-bezier(.4, 0, .2, 1) infinite;
}
@keyframes fall {
  0%   { translate: 0 -.22rem; opacity: 0; }
  30%  { translate: 0 0;       opacity: 1; }
  70%  { translate: 0 .28rem;  opacity: .5; }
  100% { translate: 0 .5rem;   opacity: 0; }
}
@media (prefers-reduced-motion: reduce) { .cue::after { animation: none; opacity: .7; } }

.eyebrow {
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: .28em;
  text-transform: uppercase;
  color: var(--ink-3);
  margin-bottom: 1.1rem;
}
.eyebrow .num { color: var(--beige); margin-right: .9rem; }

/* ── the steps ───────────────────────────────────────────────────────────────────────────── */
.steps {
  margin-top: 2.6rem;
  display: grid;
  gap: 1px;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  background: var(--hair);
  border: 1px solid var(--hair);
}
.steps li { background: rgba(255, 255, 255, .045); box-shadow: inset 0 1px 0 0 rgba(255,255,255,.07); padding: 1.6rem 1.5rem 1.8rem; display: grid; align-content: start; gap: .55rem; }
.steps .k { font-family: var(--mono); font-size: var(--fs-xs); letter-spacing: .2em; color: var(--beige); }
.steps p { max-width: 32ch; }
.note { color: var(--ink-3); font-family: var(--mono); font-size: var(--fs-xs); letter-spacing: .06em; margin-top: .35rem; }

/* ── the price ───────────────────────────────────────────────────────────────────────────── */
.tiers {
  margin-top: 2.6rem;
  display: grid;
  gap: 1px;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  background: var(--hair);
  border: 1px solid var(--hair);
}
/* ⚠ FLEX COLUMN, NOT GRID, AND THE REASON IS THE «Замовити» BUTTON. As `display: grid` with
   `align-content: start` the rows pack at the top and the leftover space belongs to no row, so
   `margin-top: auto` on the button does nothing and three tiers with 3, 4 and 4 features would put
   their buttons on three different lines. In a flex column the auto margin absorbs the free space and
   the row of buttons is level whatever the lists do. Stacked children behave identically otherwise. */
.tier { background: rgba(255, 255, 255, .045); box-shadow: inset 0 1px 0 0 rgba(255,255,255,.07); padding: 1.7rem 1.5rem 2rem; display: flex; flex-direction: column; align-items: stretch; gap: .5rem; }
.tier-hi { background: rgba(216, 205, 175, .10); box-shadow: inset 0 1px 0 0 rgba(255,255,255,.13); }
.price { font-family: var(--display); font-size: clamp(1.8rem, 3vw, 2.5rem); color: var(--ink); letter-spacing: -.03em; line-height: 1.05; }
.price span { font-size: .6em; color: var(--ink-3); }
.dur { font-family: var(--mono); font-size: var(--fs-xs); letter-spacing: .2em; text-transform: uppercase; color: var(--beige); }
/* THE LAUNCH OFFER, 2026-08-30. The struck number sits ABOVE the payable one and is deliberately
   small: it is an anchor, not a headline, and a big crossed-out figure reads as a discount shop
   rather than as a studio halving its own published price. It is also a separate node from
   <p class="price"> for a reason that is not visual at all — see the comment in index.html and
   tools/test-wayforpay.mjs. The strike is drawn in --beige rather than in the ink so the old number
   recedes instead of competing with the new one. */
.price-was { font-family: var(--display); font-size: clamp(1rem, 1.4vw, 1.2rem); color: var(--ink-3); line-height: 1; margin: 0 0 .1em; }
.price-was s { text-decoration-thickness: .07em; text-decoration-color: var(--beige); }
/* The sentence that makes the discount TRUE rather than a badge: who it is for, why it exists, and
   the two ends it has. It is prose at reading size, not a flash of urgency — the offer survives on
   being checkable, and a countdown would be the one thing here nobody could check. */
.launch { font-size: var(--fs-xs); line-height: 1.55; color: var(--beige); margin: .7em 0 0; }
.tier p:last-child { color: var(--ink-2); max-width: 28ch; }

/* ⚠ `margin-top: auto` IS THE POINT, NOT DECORATION. `.tier` is a grid with `align-content: start`,
   so three tiers whose feature lists differ in length would each park their button at a different
   height — a row of three buttons on three different lines reads as a layout accident. `auto` on the
   last child pushes it to the bottom of whichever card it is in, so the row is level whatever the
   lists do. `justify-self: start` keeps the pill its own width instead of stretching it across the
   card, which at 1440 would be a 380 px slab. */
.tier-cta { margin-top: auto; justify-self: start; }
.tier-cta:only-of-type { margin-top: 1.4rem; }

/* The echo of what was chosen. A selection the reader cannot see is one they cannot correct, and
   this form is three scroll-screens below the button that sets it. */
.lead-plan {
  margin-bottom: 1rem;
  padding: .7rem 1rem;
  border-radius: 12px;
  background: rgba(216, 205, 175, .10);
  box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, .10);
  color: var(--ink);
  font-size: .95rem;
}
.fine { margin-top: 1.4rem; color: var(--ink-3); max-width: 52ch; font-size: .92rem; }

/* ── the mid-page call to action ──────────────────────────────────────────────────────────────
   ⚠ IT EXISTS BECAUSE OF ONE MEASUREMENT, and the number is the whole justification: the enquiry
   form's top sits 6 651 px down /kuhni/ — about eight phone screens — and it was the page's only
   conversion point. `min-height` is dropped to 0 here on purpose: `.s` asks for 42svh and this
   band is three short lines, so inheriting it would open a half-screen hole in the middle of a page
   whose section rhythm is otherwise uniform (236.30 px). */
.mid-cta { min-height: 0; border-top: 1px solid var(--hair); }
.mid-cta .lede { max-width: 30ch; }
.mid-cta .fine { margin-top: 1rem; }

/* ── the works ───────────────────────────────────────────────────────────────────────────────
   ⚠ THE OLD LINK-ROW LIST THAT USED TO LIVE HERE IS DELETED, AND IT WAS NOT DEAD — IT WAS LEAKING.
   It was `.works { margin-top: 2.2rem; border-top: 1px solid var(--hair) }` plus `.works a`,
   `.works a:hover`, `.works span` and `.works em`, written for an <a><span/><em/></a> row list that
   the card grid at the foot of this file replaced. `.works a` is (0,1,1) and `.work-go` is (0,1,0),
   so the dead rule WON and redefined the portfolio's only outbound link. Measured on / and
   /roboty/ at 390x844 dpr 3, 744 and 1440:
     .work-go computed display   flex          (its own rule at .work-go asks inline-block)
     .work-go computed padding   18.4px 3.2px  (its own rule asks padding-bottom: 2px)
     .work-go box                351.27 x 55.21 at 390 — the WHOLE CARD, so its border-bottom
                                 painted a full-width hairline that read as a divider between cards
     the hairline sat            21.38 px below the bottom of the URL text — a dash in mid-air
     ink left                    h3 19.51 · p 19.51 · the link 22.71 — 3.20 px off the page rail
     gap INSIDE card 1           34.50 px, against 25.60 px BETWEEN card 1 and card 2, so proximity
                                 grouping was inverted and each URL read as a caption for the NEXT
                                 screenshot
   The markup it was written for does not exist anywhere in this repo: `.works > li > a`,
   `.works span` and `.works em` each matched ZERO elements on / and /roboty/ at every width.
   The border-top went with it: it is a ROW-LIST rule — a list whose rows each carry a
   border-bottom needs one line to close the first row — and there are no rows, so it painted a
   0.57 px hairline straight across the top of a grid of 14 px-rounded frames, running past the
   corners at both ends. No other card grid on this site has a top-only rule.
   Deleting the margin-top costs nothing: `.works` at the foot of this file sets 2.6rem and wins by
   order — measured 41.6 px both before and after. */

/* ── contact ─────────────────────────────────────────────────────────────────────────────── */
#contact .lede { margin-bottom: 2rem; }
.meta { margin: 0 0 2rem; display: grid; gap: 1px; background: var(--hair); border: 1px solid var(--hair); max-width: 44rem; }
.meta > div { background: rgba(255, 255, 255, .04); padding: .9rem 1.1rem; display: flex; justify-content: space-between; gap: 1.5rem; align-items: baseline; }
.meta dt { color: var(--ink-3); font-family: var(--mono); font-size: var(--fs-xs); letter-spacing: .16em; text-transform: uppercase; }
.meta dd { margin: 0; color: var(--ink); text-align: right; }
.meta a { text-decoration: none; border-bottom: 1px solid var(--hair-2); }

/* ── the foot ────────────────────────────────────────────────────────────────────────────── */
/* THE FOOTER IS A MAP, NOT A SIGNATURE. It said "hetoimos — студія з України. © 2026" and linked
   to nothing, on a site with six other pages — which, together with a landing that had no nav
   either, meant every one of those pages was live and UNREACHABLE. «Нет ни одной из страниц,
   которую я сказал» was exactly right, and the pages had been there the whole time. */
.foot {
  padding: 3rem max(var(--gutter), var(--safe-r)) calc(2.5rem + env(safe-area-inset-bottom, 0px)) max(var(--gutter), var(--safe-l));
  border-top: 1px solid var(--hair);
  color: var(--ink-3);
  font-size: .88rem;
}
/* FOUR COLUMNS: the brand block takes the space it needs and the three link columns share the rest.
   `auto-fit` rather than a fixed count, so it degrades to two columns on a tablet and one on a phone
   without a single extra breakpoint. */
.foot-top {
  display: grid;
  grid-template-columns: 1fr;
  gap: clamp(1.8rem, 4vw, 3rem);
  align-items: start;
}
/* ⚠ TWO COLUMNS, EXPLICITLY — `auto-fit` ORPHANED THE FOURTH BLOCK.
   There are FOUR children and minmax(190px, 1fr) fits exactly THREE tracks over a wide band, so
   «Контакти» wrapped alone to row 2 at the far left with a large void beside it. Measured at
   844x390 with touch emulation: grid 760x646, tracks 230.696 x3, gap 33.76, children at
   (0,0) (264,0) (529,0) and «Контакти» at (0,409) — a 529 x 238 px empty rectangle. Same shape at
   932x430 (547x238) and 744x1133 (466x238). Clean only at 1024 and 1366, where the explicit
   four-column rule below has taken over.
   This is the site's ONLY navigation below 860 px — .bar-nav is display:none there — so on every
   landscape phone and on iPad mini the map opened with a hole and the phone number orphaned.
   A fixed 2x2 cannot orphan. Height-neutral: the grid measures 646.33 px either way, because row 1
   is «Сторінка» (375 px) and row 2 «Контакти» (238 px) in both layouts.
   minmax(0, 1fr) rather than a bare 1fr so the long link «Умови: як ми платимо за клієнта» cannot
   force a min-content track. Accepted, measured: 975-979 px currently gets 4 auto-fit columns and
   now gets 2 — five pixels wide, no device sits there, and 2-then-4 is more consistent than
   3-then-4. Do NOT pin that sliver with a max-width: 974px; 974 is derived from the 190 px min and
   the 4vw gap and would drift silently the moment either changes. */
@media (min-width: 620px) {
  .foot-top { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 980px) {
  .foot-top { grid-template-columns: minmax(240px, 1.5fr) repeat(3, minmax(150px, 1fr)); }
}

.foot-brand { display: grid; gap: .9rem; align-content: start; }
.foot-mk-link {
  display: inline-flex;
  align-items: baseline;
  gap: .55rem;
  text-decoration: none;
  color: var(--beige);
  width: fit-content;
}
.foot-mk { height: 1.5rem; width: auto; fill: var(--beige); display: block; align-self: center; }
.foot-la { font-size: 1.05rem; letter-spacing: -.02em; color: var(--ink-2); }
.foot-tag { color: var(--ink-3); max-width: 34ch; line-height: 1.55; }
.foot-tag-2 { color: var(--ink-4); font-size: .82rem; }

.foot-col { display: grid; gap: .5rem; align-content: start; }
.foot-col h4 {
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--ink-4);
  margin-bottom: .35rem;
  font-weight: 400;
}
.foot-col a, .foot-nav a { color: var(--ink-3); text-decoration: none; transition: color .18s ease; }
.foot-nav { display: grid; gap: .5rem; align-content: start; }
.foot-note { color: var(--ink-4); font-size: .82rem; margin-top: .4rem; max-width: 26ch; }

/* The legal line, the copyright and the way back up. On a phone they stack; from a tablet the
   entity line takes the room and the other two sit at the far edge. */
.foot-bot {
  margin-top: clamp(2rem, 5vw, 3.2rem);
  padding-top: 1.4rem;
  border-top: 1px solid var(--hair);
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: .8rem 1.6rem;
  color: var(--ink-4);
  font-size: .82rem;
}
.foot-entity { flex: 1 1 24rem; max-width: 62ch; line-height: 1.5; }
.foot-c { color: var(--ink-4); font-size: .82rem; }
.foot-up {
  display: inline-flex;
  align-items: center;
  gap: .4rem;
  color: var(--ink-3);
  text-decoration: none;
  transition: color .18s ease;
}
.foot-up svg { width: 12px; height: 12px; display: block; }


/* ── the legal documents ─────────────────────────────────────────────────────────────────────
   /oferta/ and /pryvatnist/ are the only long-prose pages on this site, and they are the only two
   where the reader is looking for ONE clause rather than reading top to bottom. So: a measured
   column, numbered headings that can be linked to, and a table of contents at the top that is a real
   list of anchors. Everything here is scoped under .doc so it cannot leak into the marketing pages.
   The text itself is carried across, not retyped — see src/legal.js. */
.doc { max-width: 74ch; }
.doc h2 {
  margin: 2.6rem 0 .8rem;
  font-size: clamp(1.1rem, 2vw, 1.35rem);
  letter-spacing: -.018em;
  color: var(--ink);
  scroll-margin-top: 6rem;   /* the bar is fixed; without this an anchor lands under it */
}
.doc h3 { margin: 1.6rem 0 .5rem; font-size: 1rem; color: var(--ink-2); }
.doc p, .doc li { color: var(--ink-2); line-height: 1.7; max-width: 68ch; }
.doc p { margin-top: .7rem; }
.doc ul { margin: .7rem 0 0 1.1rem; display: grid; gap: .4rem; list-style: disc; }
.doc a { color: var(--ink); text-decoration: underline; text-underline-offset: 3px; text-decoration-color: var(--hair-2); }
.doc strong { color: var(--ink); font-weight: 500; }

.doc-toc {
  margin-top: 1.6rem;
  padding: 1.1rem 1.3rem;
  border: 1px solid var(--hair);
  border-radius: 12px;
  background: rgba(255, 255, 255, .025);
  display: grid;
  gap: .45rem;
}
@media (min-width: 700px) { .doc-toc { grid-template-columns: 1fr 1fr; gap: .45rem 1.6rem; } }
.doc-toc a { color: var(--ink-3); text-decoration: none; font-size: .9rem; }

/* A table on a phone is the one place horizontal scrolling is correct — the alternative is a column
   of unreadable slivers. The wrapper scrolls; the PAGE never does. */
.doc-scroll { margin-top: 1rem; overflow-x: auto; -webkit-overflow-scrolling: touch; }
/* ⚠ min() — A BARE 34rem OVERFLOWED ITS OWN COLUMN AT EVERY WIDTH. 34rem is 544 px and
   `section.s.doc` is 74ch, i.e. a 485 px content box at 1280 and 538 px at 744, so the table stuck
   out of the scroller on the DESKTOP too. With min(34rem, 100%) the hidden width goes 59 -> 0 at
   1280 and 6 -> 0 at 744; desktop columns land at 136/115/131/103 and nothing is clipped. */
.doc-table { border-collapse: collapse; min-width: min(34rem, 100%); width: 100%; font-size: .88rem; }
.doc-table th, .doc-table td {
  border: 1px solid var(--hair);
  padding: .55rem .7rem;
  text-align: left;
  vertical-align: top;
  color: var(--ink-2);
}
.doc-table th { color: var(--ink-3); font-weight: 400; font-family: var(--mono); font-size: var(--fs-xs); letter-spacing: .08em; text-transform: uppercase; }

/* ⚠ THE FOURTH COLUMN WAS 100% OFF-SCREEN ON EVERY PHONE, AND NOTHING SAID THE TABLE SCROLLED.
   Measured on /pryvatnist/ at 390x844 dpr 3, touch, pointer:coarse: the scroller is 351 px wide
   and the table 544, so 193 px — 35.5% — never painted. Visible pixels per column were
   151.2 / 120.5 / 79.3 / 0.00: the whole «Скільки зберігаємо» column and half of «Навіщо», cut
   mid-word («щоб зверта», «щоб відпо», «захист від»). It could be swiped to, but the affordance
   probe on that element read scrollbar thickness 0, mask-image none, box-shadow none, ::after
   content none — a reader has no way to know.
   STACKING, NOT SCROLLING, below phone width: the table's MIN-CONTENT width is 472 px, so no
   relaxation of min-width can fit four columns into a 351 px column. Only one column can.
   The breakpoint has to cover every CONTAINER narrower than 472 px, and that crossover is about
   525 px of viewport (at 520 the container is 468). 699 is used because it complements the
   existing `@media (min-width: 700px)` .doc-toc rule instead of colliding with it at exactly 700.
   Cost: the phone table goes 385 -> ~1016 px tall. That is the price of the pattern, and it is
   cheaper than a legal statement a reader cannot finish. */
@media (max-width: 699px) {
  .doc-scroll { overflow-x: visible; }
  .doc-table, .doc-table tbody, .doc-table tr, .doc-table td { display: block; }
  .doc-table { min-width: 0; width: 100%; border: 0; }
  .doc-table tr:first-child { display: none; }        /* the header row; every cell carries its own label */
  .doc-table tr { border: 1px solid var(--hair); border-top: 0; padding: .65rem .85rem; }
  .doc-table tr:nth-child(2) { border-top: 1px solid var(--hair); }
  .doc-table td { border: 0; padding: .18rem 0; }
  .doc-table td::before {
    content: attr(data-th);
    display: block;
    font-family: var(--mono); font-size: var(--fs-xs); letter-spacing: .08em;
    text-transform: uppercase; color: var(--ink-3);
  }
}

/* ── thumbs ──────────────────────────────────────────────────────────────────────────────────
   ⚠ NAVIGATION LINKS GET A THUMB-SIZED BOX ON A TOUCH SCREEN. Measured across seven devices, the
   footer's column links came out 21–23 px tall and both lockups about 22 px. That is half a thumb;
   on a phone it means the reader hits the wrong row, or nothing, and concludes the site is broken
   rather than that they missed.
   ⚠ SCOPED TO NAVIGATION, NEVER TO PROSE. The obvious version — `a { min-height: 44px }` — turns
   every inline link inside a sentence into a 44 px block and tears the paragraph apart. Only lists
   of links get this; a link inside running text keeps the line it lives on.
   Keyed on `hover: none` rather than a width, because the thing that matters is the finger, not the
   screen: a small laptop window still has a mouse, and a large tablet does not. */
@media (hover: none) {
  .foot-col a,
  .foot-nav a,
  .bar-nav a,
  .foot-up,
  .work-go {
    min-height: 40px;
    display: inline-flex;
    align-items: center;
  }
  /* `fit-content` is required — without it a grid item stretches to the whole 351 px footer column
     and the entire column width becomes a live hit box. But it also lets a SHORT label collapse:
     «Ціни» measured 30.2 x 40 px on 360/375/390/430, and `.bar-mk` — which below 640 px is the
     drawn mark alone, the word «hetoimos» being display:none — measured 23.4 x 40. min-width is a
     FLOOR, not a size, so every longer label is untouched. */
  .foot-col a, .foot-nav a { width: fit-content; min-width: 44px; }
  .bar-mk, .foot-mk-link { min-height: 40px; min-width: 44px; align-items: center; }

  /* ⚠ THE TABLE OF CONTENTS IS THE DEFINITION OF A LINK THE READER AIMS AT, AND IT GOT 22.3 px.
     All 15 .doc-toc links on /oferta/ measured 308.5 x 22.3 px on a 29.5 px pitch at 390x844 —
     the smallest tap list on the site, on a document that is 10 126 px = 12.0 phone screens, where
     the TOC is the only practical way to reach clause 11. A 7.2 px gap means a miss lands on the
     NEIGHBOURING clause, so the reader jumps to the wrong section and does not know it.
     ⚠ row-gap, NOT gap. `gap: 0` also zeroes the COLUMN gap, and this block is later in the file
     than `@media (min-width: 700px) { .doc-toc { grid-template-columns: 1fr 1fr; gap: .45rem
     1.6rem } }` at equal specificity — so on a touch tablet it measured the column gap 25.6 -> 0
     and the two columns' text 25.8 -> 0.0 px apart at 701, 744 and 1024. */
  .doc-toc { row-gap: 0; }
  .doc-toc a { min-height: 40px; display: flex; align-items: center; }

  /* The contact block's three links are the same three destinations as the footer's, and they are
     what the reader aims at in section 10 — but they are inline text at 21.1 px, half the 40 px the
     footer gives them, inside a row 73.6 px tall. Tapping the phone number is the highest-value
     action a phone visitor can take.
     ⚠ GROW THE HIT AREA, NOT THE BOX. `.meta a` carries a border-bottom hairline, so padding the
     element drags that hairline away from the text (measured 0.57 px -> 12.26 px) and leaves a
     stray rule floating in the row. An out-of-flow ::after takes the taps and touches no layout:
     measured .meta height 522.58 px before and after at 390x844, rows 73.63 unchanged.
     ⚠ tools/_devices.mjs CANNOT SEE EITHER OF THESE. Its tap check excludes
     `a:closest('p, dd, li:not(.foot-col li), .doc')`, which matches the .meta links through their
     <dd> and every .doc-toc link through .doc — and it measures getBoundingClientRect().height, so
     it cannot see a hit area extended by a pseudo-element anyway. */
  .meta dd a { position: relative; }
  .meta dd a::after {
    content: '';
    position: absolute;
    left: 0; right: 0;
    top: -.6rem; bottom: -.6rem;
  }
}

/* ⚠ NO border-radius HERE. It styles the ELEMENT, not the outline, so the moment a pill took focus
   it squared off: measured `.bar-cta` computed border-radius 999px -> 3px on .focus(), and the same
   for both hero buttons and the 14 px .work-frame. Chrome, Safari and Firefox already draw the
   focus ring following the element's own radius, so the declaration bought nothing and cost every
   rounded control its shape. A plain finger tap does not trigger :focus-visible (measured
   {focusVisible:false} after a real Input.dispatchTouchEvent tap), so this bit keyboard and
   switch-access users — including a phone with an external keyboard. */
:focus-visible { outline: 2px solid var(--beige); outline-offset: 3px; }

/* ── the phone measure caps ───────────────────────────────────────────────────────────────────
   ⚠ A max-width IN `ch` IS A CONSTANT NUMBER OF PIXELS ONCE THE TYPE SCALE STOPS SCALING, and on a
   phone it stops entirely: the set of distinct font-size/line-height pairs on this site is
   BYTE-IDENTICAL at 360, 375 and 390 (17 pairs), because every clamp() in :root sits on its rem
   floor below ~430 px. So these paragraphs stop at a fixed 184-282 px inside a column that is
   323.2 px at 360 and 387.3 px at 430 — the extra width becomes right-hand margin and the copy
   takes an extra line. Measured at 430 (column 387.3):
     .foot-note   26ch = 183.9 px -> 203.4 px unused, 52.5% of the column, 2 lines at 23 chars
     .foot-tag-2  240.5 px        -> 146.8 unused (37.9%), 2 lines
     .foot-tag    34ch = 258.1    -> 129.2 unused (33.4%), 2 lines
     .work-meta p 34ch = 281.5    -> 105.8 unused (27.3%), 3 lines
     .steps p / .grid6 p / .care p  32ch = 265.0 -> 76.4 unused (22.4%)
   The footer is a single column below 620 px, so nothing else is using that width.
   ⚠ THE CAPS ARE WIDENED, NOT REMOVED, AND EACH BREAKPOINT IS THAT ELEMENT'S OWN. A single
   `max-width: none` under (max-width: 640px) is wrong twice: at a 610 px viewport the footer is
   STILL one column of 549.3 px, so .foot-note would become a 79ch line; and `.work` is one column
   until 861, so a 640 boundary would make .work-meta p jump 576 -> 281.5 px when the window widens
   by ONE pixel. */
@media (max-width: 619.98px) {   /* the footer goes multi-column at 620 */
  .foot-tag   { max-width: 52ch; }
  .foot-tag-2 { max-width: 56ch; }  /* .82rem type, so its ch is smaller — its own value, and it
                                       must stay after .foot-tag: same specificity, order decides */
  .foot-note  { max-width: 56ch; }
}
@media (max-width: 860px) {      /* `.work` is one column until 861 */
  .work-meta p { max-width: 48ch; }
  /* `.care` goes three-up at 861 and `.grid6` at 861 too, so below it the cell is at worst half the
     grid and at best the whole column — either way wider than 32ch. */
  .grid6 p, .care p { max-width: none; }
}
@media (max-width: 843.98px) {   /* `.steps` goes three-up at 844 — see the grid note above */
  .steps p { max-width: none; }
}

@media (max-width: 640px) {
  :root { --bar-h: 3.6rem; }
  /* ⚠⚠ THE COPY FOLLOWS THE MARK INSTEAD OF SINKING TO THE FLOOR, AND THE GAP BETWEEN THEM WAS THE
     COMPLAINT. «У нас половина пустого пространства сверху просто.» He undersold it — measured at
     390x844 the <h1> began at y=514, so 61% OF THE FIRST SCREEN WAS EMPTY BEFORE THE HEADLINE, and
     the copy block used 29% of it.
     The mark is not what was wasting it. The portrait band ends around 41% of the viewport
     (tools/test-choreography.mjs models it as (edgePad + 56 + 0.34*h*0.93)/h ≈ 0.406 at this size);
     `align-content: end` then pinned the copy to the BOTTOM edge, so roughly 170 px between the
     bottom of the mark and the top of the headline belonged to neither and showed as black.
     Starting the copy just under the band closes exactly that gap and moves nothing else: the mark
     keeps its placement, src/main.js is untouched, and the choreography's «silhouette above the
     copy» contract still holds — the copy is still below the band, just no longer below everything.
     34svh is the band's own height, so the two are keyed to the same number rather than to a
     padding that happens to look right at one size. */
  /* ⚠ THE SECTION RHYTHM IS HALF OF THE 2026-08-08 COMPLAINT — «много пустых пространств».
     --sec-y is 14vh = 118.16 px at 844, and it is paid TWICE at every boundary: 236.3 px of
     black between every pair of sections, nine times on the landing ≈ 2.4 phone screens of
     nothing. On a desktop that pacing reads as air; on a 390 px column it reads as the page
     having run out. 9vh = 76 px, pair 152 px — still a clear boundary, one third less scroll.
     Inner pages inherit it, which is intended: /tsiny/ and /roboty/ carried the same complaint.
     ⚠ THIS RULE MUST STAY ABOVE THE .hero RULES BELOW — same specificity, so source order is
     what lets .hero keep its own padding-top; written after them it silently overrode the hero
     and put the headline at y=134 under the word. Measured, not hypothetical.
     The max-height 620/440 blocks at the foot of the file still override for short landscapes —
     they are later in source, same specificity, which is the order that file position guards. */
  .s { padding-block: clamp(3.25rem, 9vh, 5.5rem); }
  .hero { align-content: start; padding-top: calc(28svh + 1.5rem); padding-bottom: 2rem; }
  /* ⚠ 28svh, NOT 34: the second round of the same complaint — «верхняя часть пустая сильно».
     The 34svh fix closed the mark→copy gap but left the headline starting at y=369 of 844
     (43.7% of the first screen above the first glyph, measured). The word itself moves up with
     it: field.js shifts the portrait word by +0.165 p-units (was +0.10), so the copy stays
     BELOW the mark — the «silhouette above the copy» contract holds, both numbers moved
     together. Change either only with the other. */

  /* ⚠ THE FIRST SCREEN NO LONGER OWNS THE WHOLE SCREEN, AND THE CUE HAS TO GO WITH IT.
     A full 100svh hero on a phone means the reader's first screen ends in black with nothing to
     suggest anything follows. At 76svh the «01 Роботи» heading clears the fold by about 140 px, and
     a section visibly starting is a better invitation to scroll than a word saying «гортайте».
     ⚠ `.cue { display: none }` IS NOT TIDYING — this file already records the same trap at
     max-height:620: the cue is absolutely positioned to the hero's bottom edge, so shortening the
     hero drops it straight onto the call-to-action buttons. Measured here before hiding it: the
     buttons end at y=604 and the cue would sit at y=607.
     70svh rather than 76: at 76 the «01 Роботи» eyebrow cleared the fold by 27 px and its heading
     did not, so the reader saw a stray label. At 70 the heading itself is on the first screen,
     which is what makes the next section read as a section rather than as a crop. */
  .hero { min-height: 70svh; }
  .hero .cue { display: none; }
  /* ⚠ THE NAME IS SHOWN ON A PHONE NOW, AND IT WAS `display: none`. «На телефонной версии не видно
     текста там, хотя он там должен быть.» The mark on its own is two Greek glyphs nobody can
     pronounce, so a phone — which is where most of this funnel's traffic lands — got a logo that
     does not say who this is. Tighter tracking and a smaller size buy the room beside the
     call-to-action at 360 px, which is what the `display: none` was avoiding. */
  .bar-la { letter-spacing: .1em; text-indent: .1em; font-size: .66rem; }
  .hero .copy { max-width: none; }
  .meta > div { flex-direction: column; gap: .2rem; }
  .meta dd { text-align: left; }
}


/* ── the smooth-scroll wrapper ────────────────────────────────────────────────────────────────
   src/smooth.js takes the content out of flow and translates it toward an eased position while a
   spacer of the same height keeps the real scrollbar real. Both rules only apply once that module
   has confirmed it is running — the class is added by JS, so a page with no JavaScript, or one
   where the module threw, scrolls natively and nothing here fires.

   ⚠ NOTHING INSIDE #scroller MAY BE `position: sticky`. A translated ancestor is a containing
   block, so a sticky child sticks to the moving box instead of to the viewport and simply stops
   working. The bar is `fixed` and deliberately lives OUTSIDE the wrapper. */
/* ⚠ NO overflow ON body. THIS LINE SAID overflow: clip AND IT STOPPED THE PAGE SCROLLING.
   When the root element overflow is visible, the BODY overflow is propagated to the viewport and
   the root uses visible instead. So overflow:clip on body clips the VIEWPORT — the document still
   has 3823 px of height and a scrollHeight to prove it, and it simply cannot be scrolled. It also
   passed a test, because window.scrollTo() moves the scroll position programmatically whether or
   not a reader could. The only honest check is a real wheel event or a real hand.
   Nothing is needed here: the wrapper is fixed and out of flow, the spacer carries the height, and
   the page scrolls natively. */
html.smooth #scroller {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  will-change: transform;
}
#scroll-spacer { width: 1px; pointer-events: none; }


/* ═══════════════════════════════════════════════════════════════════════════════════════════
   THE LIVE PORTFOLIO — the idea, in CSS
   src/works.js has the reasoning and the cost. This is the material.

   The frame is rendered at 1440 and SCALED, never sized down: an iframe 480 px wide shows that
   site's PHONE layout, which is not what a prospect is being shown. --frame-scale is written by
   works.js on resize.
   ═══════════════════════════════════════════════════════════════════════════════════════════ */
.works { margin-top: 2.6rem; display: grid; gap: clamp(1.6rem, 4vw, 3rem); }

/* Two columns again, at Ihor's call: «меньше размер превью, верни как был». Full width made each
   site legible and made the section enormous — five browser windows stacked is a page of other
   people's sites, and this one is about ours. The frame still renders at 1280 rather than the
   original 1440, so it is sharper inside the smaller box than the first version was. */
.work {
  display: grid;
  gap: clamp(1.2rem, 3vw, 2.4rem);
  align-items: center;
  /* ⚠ NO content-visibility HERE, AND NO PAINT GATING EITHER — BOTH WERE TRIED AND BOTH ARE OUT.
     content-visibility: auto makes a skipped card measure its placeholder size instead of its real
     height, so the document height MOVES as cards enter and leave; on a page whose smooth scroller
     has a measured spacer that is a breathing scrollbar and drifting positions. Hiding far iframes
     was then aimed at a slowdown that turned out to be the harness measuring a background window
     (see the correction at the top of src/works.js). There is nothing to gate: at most one live
     document exists at a time and the rest are static images. */
}
/* An even card puts the frame on the right, and the COLUMN WIDTHS have to swap with it. Ordering
   alone left the frame in the 1fr column, so every second preview was 256 px tall against 410 —
   measured, and visible as a page of alternating big and small screenshots. */
/* ⚠ THE TWO-COLUMN LAYOUT LIVES INSIDE A MIN-WIDTH QUERY, AND IT DID NOT, AND THAT IS WHY THE
   CARDS WERE DIFFERENT SIZES ON A PHONE. The phone rule set one column in a max-width query, and
   the :nth-child(even) rule that swaps the columns sat AFTER it, unconditioned — so at 390px the
   odd cards were one column and the even cards were still two, with the frame in the narrow one.
   Measured at 390x844: frames came out 351x220, 128x80, 351x220, 128x80, 351x220. «Теперь разного
   размера почему-то сайты» was five cards in two different layouts on the same screen.
   A rule that only makes sense above a breakpoint belongs inside that breakpoint's query. Source
   order is not a substitute, because a later unconditioned rule always wins. */
/* ⚠ AND IT IS GATED ON SHAPE AS WELL AS WIDTH, BECAUSE A LANDSCAPE PHONE IS NARROW AND SHORT.
   With only the width gate a phone turned sideways got the ONE-column layout, and one column means
   a full-width 16/10 frame — 475 px tall inside a 390 px-tall viewport. Measured with the first
   card parked under the bar: 667x375 card 569 px = 152% of the viewport, 780x360 -> 176%,
   844x390 -> 673 px = 173%, 860x390 -> 175%; then 861x390 -> 74%. ONE PIXEL of width took the card
   from 1.75 screens to 0.74. At 844x390 `.work-meta` began at y=570, so the title, the description
   and the link were ALL below the fold: the screen showed a cropped rectangle of somebody else's
   site and nothing saying whose it was. iPhone SE (667), Galaxy S23 (780) and iPhone 14/15
   (844/852) in landscape all fall below 861; only Pro Max widths get the layout that fits.
   THE THRESHOLD IS MEASURED, NOT PICKED. Sweeping 640-860 wide by 360-620 tall, the point where
   the title returns is not a height — it is a constant SHAPE at every width:
     640 flips between 430 and 450 tall (aspect 1.49 broken | 1.42 fine)
     700 between 450 and 500 (1.56 | 1.40)   780 between 500 and 550 (1.56 | 1.42)
     860 between 550 and 600 (1.56 | 1.43)
   29/20 = 1.45 sits in all four gaps. A plain max-height cannot express it and over-reaches: 620px
   also flips 640x600 and 700x600, where the card is 92% and 98% of the viewport and the title is
   already on screen — trading a working layout for a postage-stamp preview. min-width: 640 stays
   because two columns need a text column ~200 px wide to hold the description. min-aspect-ratio is
   Media Queries Level 3, so there is no Safari 16.4 range-syntax hazard. */
@media (min-width: 861px), (min-aspect-ratio: 29/20) and (min-width: 640px) {
  .work { grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); }
  .work:nth-child(even) { grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr); }
  .work:nth-child(even) .work-frame { order: 2; }
}

/* ⚠ THE BASE RULE. It was destroyed once by an edit that sliced from the first occurrence of
   ".work-frame {" in the file — which is inside the nth-child selector above, not here — and every
   declaration below ended up scoped to even cards only. Odd cards then had NO HEIGHT: the frame is
   an empty box whose height comes entirely from aspect-ratio, so losing that rule collapses it to
   zero and the section renders as a stack of black slivers. Measured: 656 x 0.
   If this block is ever moved, check that the base selector still exists on its own. */
.work-frame {
  position: relative;
  aspect-ratio: 16 / 10;
  overflow: hidden;
  /* ⚠ `layout paint`, NOT `strict`. Containment is the second half of the fix for the multi-second
     freeze when a heavy embed boots (see the note on the iframe below): it promises the browser that
     nothing inside this box can affect layout or painting outside it, so the page does not wait on
     the child. Measured alone it was as effective as the visibility fix — 29 ms against 375 ms — and
     the two are independent, so both are here.
     `strict` adds SIZE containment, which makes the box size itself as if it were empty. This card's
     height comes from aspect-ratio against its width, so it would probably survive — «probably» is
     not a reason to take the risk on the element the whole section is made of. */
  contain: layout paint;
  border-radius: 14px;
  border: 1px solid rgba(255, 255, 255, .10);
  background: rgba(255, 255, 255, .035);
  box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, .10), 0 24px 60px -30px rgba(0, 0, 0, .9);
  transition: transform .5s cubic-bezier(.19, 1, .22, 1), box-shadow .5s ease;
  cursor: pointer;
}

/* THE SCREENSHOT is the resting state and it is REAL — the same images the studio site ships. A
   card nobody touches is still evidence, which a grey placeholder is not.
   Blurred and dimmed behind the glass; sharp when this is the card being read, and fully lit when
   the live site is running on top of it. */
.work-shot {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: top center;
  filter: blur(5px) saturate(.72) brightness(.58);
  transition: filter .55s cubic-bezier(.19, 1, .22, 1);
}
/* ⚠ `is-active`, AND IT SAID `is-focus`, WHICH NOTHING HAS EVER SET.
   `grep -rn is-focus` over sites/03-hetoimos and tools returns exactly ONE hit: this line. A second
   IntersectionObserver used to toggle that class and was deleted when the card started itself, and
   this rule was left pointing at a class that no longer exists — so the ONLY way a portfolio
   screenshot ever became legible was `.work:hover` two lines down, which a phone has not got.
   Measured at 390x844 dpr 3, hover:none: every resting .work-shot computed
   blur(5px) saturate(0.72) brightness(0.58), and `document.querySelector('.work.is-focus')` was
   null. Four of the five cards at all times, plus the fifth for the 0.4-4.5 s it is fetching, were
   dark smudges on the surface the section exists to prove itself on. Laplacian variance over the
   Зелений Гай frame: 15.4 at rest against 688.9 sharp — the embedded site's own headline is
   unreadable at rest and its nav, phone number and six product labels are readable sharp.
   works.js sets `is-active` on the ELECTED card, so exactly one card changes: the one the reader
   stopped on. brightness(.9), not 1, so the handoff to the live document still has somewhere to go.
   ⚠ THIS RULE MUST STAY ABOVE `.work:hover .work-shot`. They are both (0,2,0), so source order is
   the only thing that lets a hovered card reach brightness(1) on a mouse. Do not reorder them. */
.work.is-active .work-shot { filter: blur(0) saturate(1) brightness(.9); }

/* ⚠ A CARD THAT CAN NEVER COME ALIVE IS NEVER BLURRED, AND THE BLUR IS THE REASON THIS RULE EXISTS
   RATHER THAN BEING TIDINESS. The resting blur is a PROMISE — «this is loading, it is about to be
   the real site» — and `.work.is-active` is what redeems it. A card with no `data-embed` is never
   elected, so nothing ever redeems the promise: on a mouse only a hover would sharpen it and on a
   phone nothing would, leaving a permanent dark smudge where a screenshot is the entire evidence.
   Morion is that card since 2026-08-30 (see index.html and src/works.js for the instruction).
   The mechanism is the ATTRIBUTE, not a name: add `data-embed` back and this rule stops matching
   by itself, which is why nothing else has to be remembered when a card changes state. */
.work:not([data-embed]) .work-shot { filter: none; }

/* ⚠ ONE CARD IS LIT DIFFERENTLY, AND IT IS A PROPERTY OF THE PHOTOGRAPH, NOT A PREFERENCE.
   Ihor, 2026-08-30: «vilicor светлее сделай более заметным». The deck's resting treatment assumes a
   mid-bright screenshot and the five sources are nowhere near each other. Mean luminance of the top
   three quarters of each 1440x900 asset, measured: Зелений Гай 235.6 · Orchid Lover 219.7 ·
   77.Lab 60.0 · Vilicor 31.2 · Esport Vision 24.0. Through the resting brightness(.58) that puts
   Зелений Гай at 133 and Vilicor at 14.7 — a black rectangle where a card should be.

   ⚠ AND IT CANNOT BE FIXED BY RE-SHOOTING, WHICH IS THE FIRST THING ANYONE WILL TRY. vilicor.com is
   dark end to end: six scroll positions photographed with tools/_shot-url.mjs came back at 15.8,
   12.9, 18.6, 11.5, 11.4 and 13.8 — every one DARKER than the hero frame already shipped. There is
   no lighter part of that site to point a camera at.

   ⚠ THE LIFT IS A CONTRAST REDUCTION, NOT A BRIGHTNESS MULTIPLY, and that is the whole trick.
   brightness(1.5) alone reaches mean 34.8 and CLIPS 6.7% of the frame — and what clips first is the
   white headline and the yellow button, i.e. the two things that make the card recognisable as
   Vilicor. contrast(.7) lifts the black floor toward mid-grey instead: mean 61.6 at rest and 64.4
   sharp, with 0.00% clipped at both. Four times the resting luminance and nothing burned out.
   If the screenshot is ever replaced, RE-MEASURE — these numbers describe that file, not the card. */
.work[data-title="Vilicor"] .work-shot { filter: blur(5px) saturate(.72) contrast(.7) brightness(1.1); }
.work[data-title="Vilicor"].is-active .work-shot { filter: blur(0) saturate(1) contrast(.7) brightness(1.15); }

/* The live document, when there is one. Rendered wide and scaled — see the note in works.js. */
.work-frame iframe {
  position: absolute;
  top: 0; left: 0;
  /* ⚠⚠ THE FRAME IS OVERSIZED BY A SCROLLBAR ON BOTH AXES, ON PURPOSE. THIS IS «ЭТА ПОЛОСКА ЕБАНАЯ».
     An embedded document draws ITS OWN scrollbars, and they land on the inside edges of the frame —
     a grey track down the right of every live card and a strip along the bottom. Photographed on the
     Зелений Гай card, and it is on every one of the five because every one of them is a real site
     that scrolls.
     They cannot be styled away: the documents are cross-origin, so no stylesheet of ours reaches
     them, and ::-webkit-scrollbar on this side does nothing to another origin's viewport. The
     deprecated scrolling="no" would hide them by also FORBIDDING the scroll, which kills the thing
     the section exists to show.
     So the frame is made SCROLLBAR_PX larger than the box on each axis and `.work-frame` clips it.
     The bars are drawn outside the visible rectangle; the document still scrolls. 17 px is Chrome's
     classic scrollbar plus a pixel, in the frame's OWN pixels — at the ~0.39 scale that is about
     7 painted pixels of overhang, which is why it costs nothing visible.
     ⚠ AND THE HEIGHT IS NOT A CONSTANT EITHER. A fixed 1060 px was 3.6% short of the card and let
     the blurred screenshot behind show through as a band — a different strip, same edge, fixed
     earlier. works.js measures the card and writes --frame-h; the fallback is 16/10 at 1800 wide. */
  /* ⚠ THE WIDTH IS NOT A CONSTANT EITHER, AND THAT IS THE PHONE FIX. 1800 is a desktop viewport; on
     a 351 px phone card it scaled to 0.195 and the embedded site's body text landed near 3 px. Now
     works.js writes 430 px for a phone-width card, so the client site renders its OWN mobile layout
     at 0.816 — see the note beside RENDER_PHONE for why 430 and not the card's exact width. */
  width: calc(var(--frame-w, 1800px) + 17px);
  height: calc(var(--frame-h, 1125px) + 17px);
  border: 0;
  transform-origin: 0 0;
  transform: scale(var(--frame-scale, .4));
  /* ⚠⚠ visibility: hidden, NOT opacity ALONE — AND THIS IS «ИНОГДА ПРОСТО НЕ СКРОЛЛИТСЯ».
     An opacity-0 iframe is still COMPOSITED. While a heavy embedded app boots, the parent's frame
     production waits on the child's surface, and the page stops producing frames entirely — with
     ZERO long tasks recorded, so the main thread is idle and no main-thread profile can see it.
     Measured on the live domain, cold cache, scrolling to the Vilicor card (the only embed that is a
     full application rather than a page): the iframe is added at +9411 ms and the next frame lands
     at +14362 ms. FIVE SECONDS with the scroll frozen, and the run before it froze for 2225 ms.
     That spread across runs — 87, 375, 2225, 5175 ms in four attempts on identical code — is exactly
     the shape of «иногда происходит, но происходит»: it is a race with the child's first frame, so
     it lands differently every time and looks like a phantom.
     `visibility: hidden` takes the frame out of the composited tree until it says `load`, at which
     point works.js adds .is-live. Same measurement afterwards: 33 ms.
     ⚠ AND THE TRANSITION IS ON opacity ONLY. `visibility` is not an animatable property in the
     useful sense — it snaps — so the fade still comes from opacity while visibility does the
     decoupling. Removing either one brings the stall back. */
  visibility: hidden;
  opacity: 0;
  z-index: 1;
  transition: opacity .45s ease;
}
.work.is-live .work-frame iframe { visibility: visible; opacity: 1; }

/* The whole card answers the click: it lifts, its edge lights, and a ring runs out once. */
.work.is-active .work-frame {
  outline: 1px solid rgba(255, 255, 255, .34);
  outline-offset: 3px;
  transform: translateY(-3px);
  animation: work-take .5s cubic-bezier(.19, 1, .22, 1);
}
@keyframes work-take {
  0%   { box-shadow: inset 0 1px 0 0 rgba(255,255,255,.10), 0 0 0 0 rgba(255,255,255,.30); }
  55%  { box-shadow: inset 0 1px 0 0 rgba(255,255,255,.10), 0 0 0 12px rgba(255,255,255,.05); }
  100% { box-shadow: inset 0 1px 0 0 rgba(255,255,255,.10), 0 0 0 26px rgba(255,255,255,0); }
}
/* ⚠ THE CARD IS `is-active` FOR AS LONG AS THE EMBEDDED DOCUMENT TAKES, AND THAT IS NOT SHORT.
   Measured at 390x844 dpr 3 with mobile emulation on (so smooth.js stays off, like a phone),
   scroll-stop -> `is-live`, per card:
       Morion 853 · Зелений Гай 5315 · Vilicor 1282 · Orchid Lover 979 · 77.Lab 1236 ms
       second sweep: 894 · 8874 · 3091 · 4121 · 1687 ms
   `is-active` itself lands at +262…+295 ms every time and the rule above ALREADY reacts to it — so
   this is not "nothing happens". But that reaction is a 1 px outline, a 3 px lift and a 0.5 s ring,
   and once the ring is over the card is frozen for the remaining 0.5-8.6 s while the iframe sits at
   opacity 0 and the reader sees only the screenshot. It says CHOSEN. It does not say LOADING, and
   4 s is long enough to decide the card is a picture and scroll on — the one outcome this section
   cannot afford, because the whole claim is that these are live.
   ⚠ THE .9s DELAY IS THE FASTEST BOOT MEASURED ABOVE (853 ms), NOT A TASTE NUMBER. With no delay
   the streak sweeps for a few hundred ms and is snatched away again on the fast cards, which reads
   as a glitch rather than as progress; at .9s a boot that finishes fast paints nothing at all.
   ⚠ AND IT MUST STOP ON A DEAD SITE, HENCE :not([data-failed]). src/works.js sets data-failed after
   a 12 s timeout but never removes is-active, and nothing else reads that attribute — so the
   obvious `.is-active:not(.is-live)` would sweep FOREVER on a site that is down, promising a
   document that is never coming. That is worse than showing nothing. */
.work.is-active:not(.is-live):not([data-failed]) .work-frame::after {
  content: "";
  position: absolute;
  inset: auto 0 0 0;
  height: 2px;
  pointer-events: none;   /* z-index 2 puts it over the frame's own iframe — never take its touches */
  background: linear-gradient(90deg, transparent, var(--beige), transparent);
  z-index: 2;
  opacity: 0;
  animation: work-load 1.15s linear .9s infinite, work-load-in .35s ease .9s forwards;
}
@keyframes work-load { from { transform: translateX(-100%) } to { transform: translateX(100%) } }
@keyframes work-load-in { to { opacity: .75 } }
@media (prefers-reduced-motion: reduce) {
  .work.is-active .work-frame { animation: none; transform: none; }
  /* the streak appears; it does not travel */
  .work.is-active:not(.is-live):not([data-failed]) .work-frame::after {
    animation: work-load-in .35s ease .9s forwards;
  }
}

/* ⚠ THERE IS NO HINT ELEMENT ANY MORE, AND THAT IS THE FIX RATHER THAN A SMALLER ONE.
   It was resized three times and read as a giant slab every time, because it lived inside a box
   whose filter and whose stacking kept undoing the type scale. Ihor, on the third: «эта кнопка
   гигантская натиснет, я тебе уже пять раз сказал». A control that needs a caption explaining it
   is a control that is wrong; the cards start themselves now, so there is nothing to caption. */

.work-meta h3 { font-size: clamp(1.2rem, 2.2vw, 1.7rem); letter-spacing: -.024em; }
.work-meta p { margin-top: .5rem; color: var(--ink-2); max-width: 34ch; }
.work-go {
  display: inline-block;
  margin-top: .9rem;
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: .1em;
  color: var(--ink-3);
  /* ⚠ AN UNDERLINE ON THE TEXT, NOT A border-bottom ON THE BOX, AND THE REASON IS THE TAP BOX.
     @media (hover: none) gives this link min-height: 40px so a thumb can hit it. A border draws at
     the bottom of that 40 px box, not under the glyphs: measured on a 390x844 dpr-3 phone, the
     hairline floated 21.38 px below the URL while the leaked `.works a` rule was still padding it,
     and 14.8 px below once that rule was gone. text-decoration is painted on the TEXT, so it hugs
     it at any box height — measured 5 px under the glyphs on both the 40 px touch box and the
     17.85 px desktop box. Do not "simplify" this back to a border. */
  text-decoration: underline;
  text-decoration-color: var(--hair);
  text-decoration-thickness: 1px;
  text-underline-offset: 5px;
}

/* ── the sections the old site had and this one did not ─────────────────────────────────── */
.feats { margin-top: .9rem; display: grid; gap: .45rem; }
.feats li {
  position: relative;
  padding-left: 1.1rem;
  color: var(--ink-2);
  font-size: .95rem;
}
.feats li::before {
  content: "";
  position: absolute;
  left: 0; top: .62em;
  width: .38rem; height: .38rem;
  border-radius: 50%;
  background: var(--beige);
  opacity: .55;
}

.grid6, .care {
  margin-top: 2.4rem;
  display: grid;
  gap: 1px;
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
  background: var(--hair);
  border: 1px solid var(--hair);
}
.grid6 li, .care li {
  background: rgba(255, 255, 255, .045);
  box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, .07);
  padding: 1.5rem 1.4rem 1.7rem;
  display: grid;
  align-content: start;
  gap: .5rem;
}
.grid6 p, .care p { color: var(--ink-2); max-width: 32ch; }

/* ⚠ THREE ITEMS NEVER GO IN TWO TRACKS, AND SIX NEVER GO IN FOUR.
   `repeat(auto-fit, minmax(…, 1fr))` picks its track count from the width alone and knows nothing
   about how many items there are, so wherever the count does not divide the items the grid paints
   a cell holding NOTHING — and because the grid carries `background: var(--hair)` and a 1 px border
   while each item adds rgba(255,255,255,.045) on top, that empty cell renders as a blank FRAMED
   panel, 8/255 darker than its neighbour. It reads as a card that failed to load.
   Measured 2026-08-07 on localhost:8815, coarse pointer, / and /tsiny/:
     .steps/.tiers (15rem)  2 tracks at every width 560-843  -> rows 2+1, 336x330 empty at 744
     .care         (16rem)  2 tracks at every width 600-855  -> rows 2+1, 381x175 empty at 844
     .grid6        (16rem)  4 tracks once the grid is >= 4*16rem+3 = 1027 px, i.e. viewport >= ~1180
                            -> rows 4+2, a 533-562 px empty region at 1180/1366/1440/1920/2560.
                            The grid saturates at 1088 px under `.s { max-width: 78rem }`, so it
                            never recovers — that was the DEFAULT desktop look of / and /mozhemo/.
   Track counts must DIVIDE the item count: 1 or 3 for the three-item grids, never 2; 1, 2 or 3 for
   .grid6, never 4. Both breakpoints are the width at which auto-fit already chose 3 by itself, so
   every width that was correct before is untouched.
   ⚠ THIS MUST STAY AFTER the `.grid6, .care` rule above — `.care` is set there and these override
   at equal specificity, so source order is what decides.
   `node tools/_devices.mjs` cannot catch this class of defect: an empty cell overflows nothing and
   has no tap target, so it reports PASS either way. */
.steps, .tiers, .care { grid-template-columns: 1fr; }
@media (min-width: 844px) { .steps, .tiers { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (min-width: 861px) { .care, .grid6 { grid-template-columns: repeat(3, minmax(0, 1fr)); } }

.rows { margin-top: 2.2rem; border-top: 1px solid var(--hair); }
.rows > div {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr);
  gap: 1.5rem;
  /* ⚠ NO SIDE PADDING. `.2rem` = 3.20 px pushed every heading and every paragraph in this list
     3.20 px right of the page's left rail, which is dead straight at 19.51 px for 64 other elements
     at 390x844 (measured with a DOM Range over the first glyph — getBoundingClientRect cannot see
     a text-indent). Ten children here and eighteen in .qa, i.e. every guarantee and every FAQ
     question, indented by an amount too small to read as an indent and too large to read as
     alignment, directly under an h2 sitting on the true rail. These boxes have no background and no
     border of their own — only the row's border-bottom — so the padding bought nothing. */
  padding: 1.35rem 0;
  border-bottom: 1px solid var(--hair);
  align-items: start;
}
.rows p { color: var(--ink-2); max-width: 56ch; }

.qa { margin: 2.2rem 0 0; border-top: 1px solid var(--hair); }
.qa > div {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.6fr);
  gap: 1.5rem;
  padding: 1.25rem 0;   /* see the note on .rows > div — same 3.20 px rail break, 18 elements */
  border-bottom: 1px solid var(--hair);
  align-items: start;
}
.qa dt { font-family: var(--display); color: var(--ink); font-size: 1.02rem; letter-spacing: -.012em; }
.qa dd { margin: 0; color: var(--ink-2); max-width: 56ch; }

@media (max-width: 860px) {
  .rows > div, .qa > div { grid-template-columns: 1fr; gap: .4rem; }
}

/* ── the inner pages ──────────────────────────────────────────────────────────────────────────
   Generated by tools/build-het.mjs from src/pages.js. The shell is cut out of index.html, so
   everything below is only what an inner page needs that the home page does not. */

/* The nav exists on inner pages ONLY. A landing with six links in the corner is six things a
   prospect reads past before the one claim; a reader who has already gone one level in has chosen
   to look around, and there the links are a service. */
/* ⚠⚠ THE BAR HAD NO PHONE RULE AT ALL AND RAN OFF THE SCREEN ON EVERY PHONE.
   Measured on the live site: `a.bar-cta` sat 207 px past the right edge on a Galaxy S23, 192 px on
   an iPhone SE, 178 px on an iPhone 14, 140 px on a 14 Pro Max. The call to action — the one control
   the whole page exists to get pressed — was off the screen on every handset, and the five nav links
   were 21–23 px tall, well under a thumb.
   It went unnoticed because the bar is `position: fixed`: a fixed element that overflows does NOT
   widen the document, so the page never scrolled sideways and nothing looked wrong in a narrow
   window. Only asking each element for its rectangle finds it.
   Below 860 px the nav leaves the bar — every one of those links is in the footer map, which is
   where a phone reader looks for them anyway — and the CTA keeps its place at a real tap size.
   ⚠ THAT FIX ALSO ADDED `.bar { padding-inline: clamp(1rem, 4vw, 1.6rem) }` HERE, AND IT IS GONE.
   It put the bar on a SECOND RAIL: 4vw can never reach --gutter's 5vw, so the bar could only ever
   be narrower than the page. Measured lockup-left against section-content-left —
   360: 16.00 vs 18.40 · 390: 16.00 vs 19.51 · 430: 17.21 vs 21.51 · 744: 25.60 vs 37.20 ·
   844: 25.60 vs 42.20 — then 0.00 at 860, a 17 px snap across the breakpoint. Identical delta on
   the right edge, so the CTA also ended outside the column every headline below it sits on.
   It was never what fixed the overflow either: removing five nav links is what freed the room,
   and this line moved 2-4 px. Measured slack between the lockup and the CTA after deleting it is
   still ~99 px at a 280 px viewport. The base `.bar` rule is the only rail the bar needs. */
.bar-nav { display: flex; gap: clamp(.9rem, 2vw, 1.7rem); margin-left: auto; margin-right: 1.2rem; }
@media (max-width: 859px) {
  .bar-nav { display: none; }
  .bar-mk { padding: .5rem 0; }
  /* ⚠ 34ch IS A LATIN UNIT AND IT FROZE THE LEDE AT 299.115 px ON EVERY PHONE.
     `ch` is the advance of "0", so a ch cap scales with the font — and `--fs-lede` sits on its
     1.02rem floor below ~1125 px of width, which makes 34ch a CONSTANT 299.115 px while the column
     grows: 24.1 px unused at 360, 38.8 at 375, 52.1 at 390, 88.2 at 430 (22.8% of the column), and
     405 px — 58% of the column — at 844 in landscape. The Ukrainian average advance is 7.72 px
     against the "0" advance of 8.80, so 34ch buys only 34-38 real characters, ~20% under the 45-75
     comfort range. Cost measured at 430 with the cap released: all seven ledes on / drop exactly
     25.3 px, one line each, 177 px of dead vertical space, and #contact stops orphaning «дзвінків.»
     on a line of its own.
     min(), not a release: a full release measured 86-91 characters per line at 844 landscape.
     Verified 430 -> 387.3 px / 42-50 chars (identical to a full release), 640 -> 457.6, 844 ->
     457.6 / 54-58 chars. >= 860 is untouched at 340.2 px, so desktop is provably unchanged. */
  :root { --measure: min(100%, 52ch); }
  .bar-cta {
    margin-left: auto;
    padding: .62rem 1.05rem;
    font-size: .86rem;
    min-height: 40px;
    /* ⚠ NOT inline-flex. `.bar` is display:flex, so an inline-flex child BLOCKIFIES to computed
       `flex`, and then each contiguous text run inside the pill becomes its own ANONYMOUS FLEX
       ITEM — and flexbox DISCARDS the white space between items. The markup is three runs,
       `Побачити<span class="bar-cta-long"> свій</span> сайт`, so the one control the whole page
       exists to get pressed rendered as «Побачитисвійсайт», ONE RUN-ON WORD.
       Measured with a Range over the single space character (setStart(n,i), setEnd(n,i+1)) —
       a Range over the whole run reads 0 at every width including the healthy ones and cannot see
       this bug:
           vw 360 / 390 / 428   flex, one item    3.73 px   correct
           vw 430 … 858         flex, three items 0.00 px   ← the defect
           vw 859+              block             3.82 px   correct
       It starts at exactly 430 because the query below hides `.bar-cta-long` at <= 429, the two
       remaining runs merge into ONE anonymous item, and the space inside an item is not at a
       boundary. `display: block` is precisely what >= 859 already computes and is verified there;
       it restores the font's own space (3.90 px = 3.77 glyph + .01em letter-spacing) and puts
       `innerText` back to a sentence rather than three lines.
       NOT A HEIGHT TRAP: measured 42.30 px tall and 158.98 wide either way, bar 57.59 px either
       way, because `min-height: 40px` never binds (the content box is already 40.02-42.30) and so
       `align-items: center` was inert — it is dropped here, not moved.
       ⚠ DO NOT "fix" this with `line-height: 40px; padding-block: 0` instead. The
       @media (max-width: 429px) block BELOW re-asserts the `padding` SHORTHAND, which puts 9.28 px
       of vertical padding back on top of a 40 px line box: the pill becomes 60.5 px inside a
       57.6 px bar and pokes out of it on every 360/375/390 phone.
       ⚠ tools/_devices.mjs CANNOT CATCH THIS. It checks viewport overflow, tap size, text size and
       card widths; a missing word space breaks none of them. It reported PASS at 430 and 744. */
    display: block;
    white-space: nowrap;
  }
}
/* Narrower still: the full sentence does not fit beside the lockup at 360 px, so the button says
   what it does in two words rather than being cropped or wrapping into two lines. */
@media (max-width: 429px) {
  .bar-cta { font-size: .82rem; padding: .58rem .9rem; }
  .bar-cta .bar-cta-long { display: none; }
}
.bar-nav a, .bar-here {
  font-size: .84rem;
  text-decoration: none;
  color: var(--ink-3);
  white-space: nowrap;
  transition: color .18s ease;
}
.bar-here { color: var(--ink); border-bottom: 1px solid var(--beige); padding-bottom: 2px; }

/* An inner hero is half a screen, not a whole one: the reader arrived on purpose and the thing they
   came for should not be one scroll below the fold. */
.hero-page { min-height: 52svh; }
/* ⚠⚠ AN h1 IN THIS FACE HOLDS 12-14 CHARACTERS PER LINE. MEASURED, AND IT IS A WRITING RULE MORE
   THAN A CSS ONE — DO NOT RE-DERIVE IT, AND DO NOT TRY TO SOLVE A LONG HEADLINE HERE.
   Probed with a nowrap span in the computed face, size and tracking of `.hero-page h1`, against the
   column it actually gets:

       360 px  font 36.8   column 323   13 chars      744 px  font 38.7   column 633   22 chars
       390 px  font 36.8   column 351   13 chars     1440 px  font 67.2   column 704   14 chars

   So a page heading fits TWO lines at every width only if it is about 26 characters or fewer, and
   1440 is no more forgiving than a phone — the type grows with the viewport, so the character count
   barely moves. /magazyn/ shipped a 38-character h1 and rendered FOUR lines on a phone, pushing the
   lede and both buttons off the first screen.
   The cap below does not bind on a phone at all (18ch is wider than the viewport there); it only
   stops a long heading running the full width of a desktop. The real constraint is the writing. */
.hero-page h1 { max-width: 18ch; }

/* ══ THE ANSWERING FIRST SCREEN — /sait/ and /kvyz/ ═══════════════════════════════════════════
   ⚠ EVERY NUMBER IN THIS BLOCK COMES FROM ONE MEASUREMENT. Microsoft Clarity's scroll map for
   /kuhni/ on 2026-08-12, n=20 paid arrivals: **80% reached 5% of the page, 20% reached 10%, 10%
   reached 25%.** Measured in a real Chrome at 390x844 the page is 9 385 px of scroll — 12.1 screens
   — so 5% is HALF A SCREEN and 10% is one. Four of every five readers never finished the first
   screen, and the enquiry form sits at 8 619 px, which is 92% of the way down.
   Over the same window the tap map recorded TWO taps across those 20 views, one of them on the h1,
   which is not a control. Nobody opened a portfolio piece — the asset the whole creative is built on.

   ⇒ So the first screen has to answer the three questions a cold reader actually has, in the order
   they have them: WHAT DOES IT COST · SHOW ME · WHAT DO I DO. `.hero-tight` gives up the half-screen
   of air that `.hero-page` spends on presence, and buys three real proofs with it. */
.hero-tight { min-height: auto; padding-top: calc(var(--sec-y) * .8); }
.hero-tight .acts { margin-top: 1.3rem; }
.hero-tight .lede { margin-top: .9rem; }

/* The kicker names the buyer. On a country-wide Advantage+ audience with no interests and no
   language filter the first words are the ONLY filter that exists, and that is as true of the
   landing as it is of the ad. Reuses .eyebrow's tracking without its section-numbering slot. */
.kick {
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: .22em;
  text-transform: uppercase;
  color: var(--beige);
  margin: 0 0 .9rem;
}

/* The price, the term and the risk on ONE line, above the fold, in the reader's own units.
   Not a table and not a section — a sentence they cannot scroll past. */
.facts {
  margin-top: 1rem;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: .5rem .9rem;
  font-size: 1.02rem;
  color: var(--ink-2);
}
.facts b { color: var(--ink); font-weight: 600; font-size: 1.18rem; }
/* ⚠ THE SEPARATOR IS A TRAILING ::after, NEVER A LEADING ::before, AND THIS EXACT DEFECT WAS
   ALREADY FIXED ONCE ON /pro/ — a wrapped line opening with a naked «·». At 390 px the three facts
   do not fit on one line, so «передоплати немає» wraps; with a leading dot the second line began
   «· передоплати немає», which reads as a bullet nobody asked for. A trailing dot stays at the end
   of the line it belongs to, where a wrap makes it invisible instead of wrong. */
.facts > :not(:last-child)::after { content: '·'; color: var(--ink-3); margin-left: .9rem; }

/* THE PROOF STRIP. Three real sites, tappable, inside the first screen — because the five-card
   portfolio 4 000 px below was opened ZERO times out of 20. Horizontally scrollable rather than
   wrapped: a 3-up grid at 390 px gives 108 px thumbnails, which is a logo, not evidence. */
.proof { margin-top: 1.6rem; padding: 0; list-style: none; display: grid; gap: .7rem; grid-template-columns: repeat(3, 1fr); }
.proof a { display: block; text-decoration: none; color: inherit; }
/* ⚠ `height: auto` IS THE LOAD-BEARING LINE, NOT THE aspect-ratio ABOVE IT. The markup carries
   width="1200" height="750" so the browser reserves the box before the image lands (this strip is
   above the fold; a reflow there moves the price line under the reader's thumb). But a `height`
   ATTRIBUTE is a specified height, and `aspect-ratio` only resolves when one axis is `auto` — so
   with width:100% and the attribute both definite, the ratio was ignored entirely. Measured at
   390 px before this line: each tile rendered **148 x 750**, a tower, and the strip was 797 px tall,
   which is the whole fold plus some. With it: 148 x 111. */
.proof img {
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  object-position: top center;
  border: 1px solid var(--hair);
  border-radius: 10px;
  background: #0b0e13;
}
.proof span { display: block; margin-top: .45rem; font-size: .82rem; color: var(--ink-3); line-height: 1.3; }
.proof a:hover img { border-color: var(--beige); }
.proof-note { margin-top: .9rem; font-size: .9rem; color: var(--ink-3); }
/* As a label it wants air above and almost none below — it belongs to the strip under it. */
.proof-note-lead { margin-top: 1.4rem; margin-bottom: -.5rem; }

/* ══ THE REASON ════════════════════════════════════════════════════════════
   It sits between the action and the proof strip and answers the question the reader asks silently
   at exactly that point: «how can that possibly be free». Measured: 143 of 661 paid arrivals
   reached the enquiry form and TWO typed.
   ⛔ «AND THE PERSON» STOOD IN THIS HEADING AND IS STRUCK, 2026-08-30. The second answer here was
   «who am I writing to», drawn as `.who` — a 48 px round portrait beside «Відповідає Ігор». Ihor
   removed it by name, together with the /pro/ card it was lifted from, so `.who .who-face .who-txt`
   are deleted rather than left as dead rules that a later card would silently inherit — which is
   exactly what the /pro/ card did to this strip for three weeks. The 143-of-661 wall is real and
   now has ONE answer instead of two; do not answer the second half with a face again. */
.why {
  margin-top: 1.1rem;
  font-size: .93rem;
  line-height: 1.5;
  color: var(--ink-2);
  max-width: 46ch;
  border-left: 2px solid var(--beige);
  padding-left: .8rem;
}
@media (max-width: 430px) {
  /* At 390 px three columns leave 111 px per tile. A tile that small proves nothing, so the strip
     becomes a swipe: two and a bit visible, the third half-showing, which is what says «there are
     more». */
  .proof { grid-template-columns: none; grid-auto-flow: column; grid-auto-columns: 42%; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; }
  .proof > li { scroll-snap-align: start; }
}

/* ⛔ THE QUIZ — /kvyz/ — WAS STYLED HERE AND THE PAGE, src/quiz.js AND THESE 40 RULES ARE ALL
   DELETED, 2026-08-30, with the other five audience/variant pages. The design note is not worth
   keeping; the measurement that killed the idea is: 178 quiz_start against SEVEN quiz_step over the
   campaign — 3.9% answered ONE question, against 16.7% of arrivals reaching the ordinary enquiry
   form on the page arms. «Remove reading entirely: four taps, then the contact field» is REFUTED,
   and rebuilding it needs a new reason, not the old hypothesis. */

/* ⛔ «ХТО ВІДПОВІДАЄ» — THE PERSON CARD ON /pro/ WAS STYLED HERE AND BOTH THE CARD AND ITS 165
   LINES OF CSS ARE REMOVED, 2026-08-30, on Ihor's explicit instruction. Markup and the reason:
   src/pages.js, entry `pro`. The measured layout notes that lived here (the -10px role/name
   overlap, the zero clearance under the h2, the 113-character measure, the portrait spilling out
   of an `overflow: hidden` that was on the card instead of the cell) described that card only and
   went with it, together with tools/test-who-card.mjs.
   ⚠ ONE FACT FROM THEM IS WORTH MORE THAN THE REST AND IS WHY NO `.who` RULE IS LEFT ANYWHERE:
   the card's selector was a bare `.who`, declared AFTER the `.who` flex row that the one-line
   «Відповідає Ігор» strip on /biznes/ and /sait/ was written against, so it silently captured that
   strip too. Measured on /biznes/ before the deletion: `display: grid`, `max-width: 832px`,
   `background: rgba(9,11,15,.86)` and a hairline border — a plated card nobody designed, on two
   live ad landings, for three weeks. The strip itself was removed hours later on Ihor's next
   instruction, so the whole family of rules is deleted rather than kept dead: a dead `.who` is
   precisely what the next element named `.who` would inherit without anybody writing a line. */


/* ── the stack ────────────────────────────────────────────────────────────────────────────────
   Twenty-one marks in their own brand colours on a near-black ground. The colours are the point —
   a row of monochrome logos is a decoration, and a row in the real colours is a claim you can check
   against what you already recognise. */
.stack {
  margin-top: 2.4rem;
  display: grid;
  gap: 1px;
  grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
  background: var(--hair);
  border: 1px solid var(--hair);
}
.stack li {
  background: rgba(255, 255, 255, .04);
  box-shadow: inset 0 1px 0 0 rgba(255, 255, 255, .06);
  padding: .95rem 1rem;
  display: flex;
  align-items: center;
  gap: .75rem;
  min-width: 0;
}
.stack-i {
  flex: 0 0 auto;
  width: 1.35rem;
  height: 1.35rem;
  display: grid;
  place-items: center;
  /* The marks are drawn at their own saturation and would shout on a page this dark, so they are
     held back a little and come up to full on hover. The row reads as a texture until you look at
     one of them, which is the right order for a list nobody reads top to bottom. */
  opacity: .82;
  transition: opacity .2s ease;
}
.stack-i svg { width: 100%; height: 100%; display: block; }
.stack b {
  font-weight: 500;
  font-size: .9rem;
  color: var(--ink-2);
  letter-spacing: -.004em;
  overflow-wrap: anywhere;
}


/* ═══════════════════════════════════════════════════════════════════════════════════════════
   ⚠ EVERY HOVER RULE ON THIS SITE LIVES IN HERE, AND THAT IS NOT TIDINESS.
   A tap LATCHES :hover on a touch screen and it STAYS latched until the reader taps something
   else. Measured with a real Input.dispatchTouchEvent press on `.bar-cta` at 390x844 dpr 3,
   pointer:coarse / hover:none — the tap produces the touch-compatibility mouse events
   (pointerover, mouseover, mouseenter … every one carrying
   sourceCapabilities.firesTouchEvents = true), and `mouseover` is what sets :hover:
       el.matches(':hover') at +150 ms  true      background rgba(255,255,255,.09) -> .13
                             +1650 ms   true      .13
                             +3650 ms   true      .13
       after a tap at (8, 400)          false     back to .09
   Sampled the same way across the site before this block existed: `.acts .btn` held .17 fill AND
   translateY(-1px) forever, `.btn-q` held .09 and full-brightness text, `.work-go` held an 11.2 px
   padding-left shift OF THE LINK JUST TAPPED, and a `.work` card held blur(0) saturate(1)
   brightness(1) with its frame lifted 2 px. On a phone the bar is on screen 100% of the time, so a
   reader who presses «Побачити свій сайт» leaves it lit for the rest of the session — it reads as
   stuck, or as if the tap did not take.
   `@media (hover: hover)` is the exact complement of the `@media (hover: none)` block above and
   consistent with its own note («the thing that matters is the finger, not the screen»).
   ⚠ NOT `and (pointer: fine)` — that leaves a hover:hover + pointer:coarse device in NEITHER
   branch. Both forms measured green at 641 / 861 / 1440; this one is the total one.
   ⚠ AND THE `:active` RULES ARE DELIBERATELY *NOT* IN HERE. A press is real on both input kinds,
   and they must stay LATER in the file than the hover rules so a press beats a hover at equal
   specificity — .bar-cta:active and .btn:active are declared above, so they are re-declared at the
   foot of this block. Honest limit: touch `:active` is NOT verified by this harness —
   Input.dispatchTouchEvent produced `:active === false` at every 40 ms sample across a 720 ms
   press, while a real mouse press on the same element gives true. The rules are the correct
   mechanism for Android/iOS but they are asserted, not measured. -webkit-tap-highlight-color is
   left unset on purpose, so the platform flash remains as the fallback feedback.
   ═══════════════════════════════════════════════════════════════════════════════════════════ */
@media (hover: hover) {
  .bar-cta:hover { background: rgba(255, 255, 255, .13); border-color: rgba(255, 255, 255, .34); }
  .btn:hover {
    background: rgba(255, 255, 255, .17);
    border-color: rgba(255, 255, 255, .32);
    transform: translateY(-1px);
  }
  .btn-q:hover { background: rgba(255, 255, 255, .09); color: var(--ink); }
  .foot-mk-link:hover .foot-la { color: var(--ink); }
  .foot-col a:hover, .foot-nav a:hover { color: var(--ink); }
  .foot-up:hover { color: var(--ink); }
  .doc a:hover { text-decoration-color: var(--beige); }
  .doc-toc a:hover { color: var(--ink); }
  /* stays AFTER `.work.is-active .work-shot`, so a hovered card still reaches brightness(1) */
  .work:hover .work-shot { filter: blur(0) saturate(1) brightness(1); }
  /* ⚠ AND VILICOR NEEDS ITS OWN, OR HOVER UNDOES THE LIFT. The rule above is (0,3,0) and sits later
     in the file than `.work[data-title="Vilicor"] .work-shot`, which is also (0,3,0) — so a mouse
     hovering the darkest card in the deck would drop it from mean luminance 61.6 back to 25.4, i.e.
     hovering it makes it HARDER to see. Measured on the same asset as the block near line 1060. */
  .work[data-title="Vilicor"]:hover .work-shot { filter: blur(0) saturate(1) contrast(.7) brightness(1.15); }
  .work:hover .work-frame { transform: translateY(-2px); }
  .work-go:hover { color: var(--ink); text-decoration-color: var(--hair-2); }
  .bar-nav a:hover { color: var(--ink); }
  .stack li:hover .stack-i { opacity: 1; }
  .stack li:hover b { color: var(--ink); }
}
/* Re-declared here so a press wins over a hover on a machine that has both. */
.bar-cta:active { background: rgba(255, 255, 255, .20); border-color: rgba(255, 255, 255, .40); }
.btn:active { background: rgba(255, 255, 255, .22); transform: translateY(1px); }


/* ═══════════════════════════════════════════════════════════════════════════════════════════
   ⚠ THIS BLOCK ONLY WORKS AS THE LAST RULE IN THE FILE, AND THAT IS MEASURED, NOT STYLE.
   Every declaration in it ties on specificity with one already above — `.s { padding }`,
   `.hero h1 { max-width }`, `.cue { display: grid }`, `.hero { padding-bottom: 5.5rem }` in the
   max-width:640 block — so source order is the only thing deciding it. Inserted BEFORE that 640
   block instead of after, the hero padding stays 88 px at every viewport and all four landscape
   phones keep both buttons off-screen.

   WHAT IT FIXES. EVERY PHONE RULE IN THIS FILE IS KEYED ON WIDTH (640, 859, 860, 429) AND A PHONE
   IN LANDSCAPE IS 667-932 px WIDE — wider than all of them. So a handset turned sideways keeps the
   DESKTOP bar at 4.25rem, and `--sec-y: clamp(5.5rem, 14vh, 11rem)` reaches its 14vh term only
   above 628 px of viewport height, which means on a short screen the 5.5rem FLOOR binds and spends
   176 px of a 390 px screen — 45% — as section padding. Measured on `/`, BOTH hero call-to-action
   buttons sat entirely below the fold on every handset:
       667x375  −51.7 px      780x360  −122.0 px
       844x390  −102.6 px     932x430  −77.2 px      568x320  −92.9 px    640x360  −53.3 px
   and the lede was cut mid-sentence at 780x360 (−44.4) and 844x390 (−25.0). Inner routes too:
   /roboty/ −77.3 at 844x390. A visitor who turns the phone sideways — or lands there from a video
   app — got a first screen with no action on it and a sentence sliced in half.
   Only a HEIGHT-keyed query can see any of it.

   ⚠ `.cue { display: none }` IS LOAD-BEARING, NOT TIDYING. Shortening the hero lifts the
   absolutely-positioned cue onto the buttons — measured 32.6x89.2 px of overlap at 844x390 and
   667x375, 23.9x89.2 at 932x430. Narrowing this hide to `max-height: 500px` to keep the cue where
   it is visible today was tried and REVERTED: it leaves a 22.3x89.3 px overlap at 320x568 PORTRAIT,
   because the padding compression reaches viewports the narrower hide does not.

   ⚠ 620 IS THE CEILING BECAUSE PORTRAIT PHONES START AT 640 px TALL. Raising it to 660 to catch
   621-660 px laptop windows was measured and pulls 360x640 and 320x568 portrait into this
   treatment. Those windows need their own item, not a wider query here.
   KNOWN REMAINING GAP, pre-existing and NOT introduced by this: viewport heights 621-660 on wide
   windows still miss — 1366x640 by 4.0 px, 1440x621 by 27.6 px. Same root cause, different device
   class (a laptop window, not a phone).
   ⚠ tools/_devices.mjs CANNOT REGRESS THIS on its own: its seven devices are portrait except iPad
   Pro landscape 1366x1024, and 1024 is above this query. Four landscape rows are added there.
   ═══════════════════════════════════════════════════════════════════════════════════════════ */
@media (max-height: 620px) {
  .s { padding-block: clamp(2.2rem, 8vh, 5.5rem); }
  .hero .lede { max-width: 46ch; }
  .cue { display: none; }
}
/* Landscape phones are <= 440 px tall. 2.2rem still left the shortest of them short — Galaxy S23
   landscape 780x360 missed by 1.0 px and 568x320 by 14.8 px with the 620 block alone. */
@media (max-height: 440px) {
  .s { padding-block: clamp(1.6rem, 6vh, 5.5rem); }
  .acts { margin-top: 1.25rem; }
}


/* ══════════════════════════════════════════════════════════════════════════════════════════════
   THE ENQUIRY FORM
   The only element on this page that earns money, so it gets the largest tap targets on the site
   and the fewest decisions. Four fields, two of them optional, no dropdown — every extra field is
   a reason to close the tab.
   ══════════════════════════════════════════════════════════════════════════════════════════ */
.lead { margin-top: 2.2rem; display: grid; gap: 1.1rem; max-width: 40rem; }
.lead-row { display: grid; gap: .5rem; }
.lead-row label {
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--ink-3);
}
.lead input, .lead textarea {
  font: inherit;
  /* ⚠ 16 px MINIMUM ON THE CONTROL, AND THIS IS THE ONE RULE NOT TO "TIDY UP" ON A PHONE.
     iOS Safari ZOOMS the whole page when a focused input's font-size is under 16 px, and it does
     not zoom back out — the reader is left on a horizontally scrolled page in the middle of the
     form. 1rem is 16 px here. The temptation to shrink it lives in exactly the phone breakpoint
     where doing so breaks it. */
  font-size: 1rem;
  color: var(--ink);
  background: rgba(255, 255, 255, .045);
  border: 1px solid var(--hair);
  border-radius: 12px;
  padding: .95rem 1.05rem;
  width: 100%;
  min-height: 3rem;
  transition: border-color .18s ease, background .18s ease;
  -webkit-appearance: none;
  appearance: none;
}
.lead textarea { resize: vertical; min-height: 5.5rem; line-height: 1.5; }
.lead input::placeholder, .lead textarea::placeholder { color: var(--ink-4); }
.lead input:focus, .lead textarea:focus {
  outline: none;
  border-color: var(--hair-2);
  background: rgba(255, 255, 255, .07);
}
.lead input:focus-visible, .lead textarea:focus-visible { outline: 2px solid var(--beige); outline-offset: 2px; }

.lead-two { display: grid; gap: 1.1rem; }
@media (min-width: 620px) { .lead-two { grid-template-columns: 1fr 1fr; } }

/* ⚠ display:none, NOT off-screen positioning. Chrome autofill and password managers FILL an
   off-screen input and SKIP a hidden one. The endpoint's own file records a real prospect lost to
   exactly that, on a honeypot named `company` sitting off-screen. */
.lead-hp { display: none !important; }

.lead-act { display: flex; flex-wrap: wrap; align-items: center; gap: .9rem 1.4rem; margin-top: .2rem; }
.lead-act .btn { min-width: 11rem; text-align: center; }
.lead-note { color: var(--ink-3); font-size: .88rem; max-width: 34ch; line-height: 1.45; }
.lead-note[data-state="bad"] { color: var(--beige); }
.lead button[disabled] { opacity: .6; cursor: default; }

.lead-done {
  margin-top: 2.2rem;
  padding: 1.6rem 1.7rem;
  border: 1px solid var(--hair-2);
  border-radius: 16px;
  background: rgba(255, 255, 255, .045);
  max-width: 40rem;
}
.lead-done h3 { font-family: var(--display); font-size: 1.3rem; letter-spacing: -.02em; color: var(--ink); }
.lead-done p { margin-top: .5rem; color: var(--ink-2); }

/* THE SUBSCRIPTION PRICE. «Від» is set at the same weight as the number so it cannot be skimmed
   past — a reader who reads «1 500 ₴/міс» and misses the «від» has been misled by typography, which
   costs the same as being misled by the copy. */
.care-price {
  margin-top: .55rem;
  font-family: var(--display);
  font-size: clamp(1.35rem, 2.4vw, 1.7rem);
  letter-spacing: -.03em;
  color: var(--beige);
  line-height: 1.1;
}
.care-price span { font-family: var(--mono); font-size: .5em; letter-spacing: .06em; color: var(--ink-3); }


/* ⚠ THE PREVIEW GOES EDGE TO EDGE ON A PHONE — «маленькое какое-то говноокошко ебаное».
   The card sat inside the section's gutter like every other block, which on a 390 px screen left it
   350 px wide showing a 1280 px desktop viewport: the embedded site's body text landed near 4 px.
   The gutter is right for TEXT — a paragraph needs a margin to be readable — and wrong for the one
   element on this page whose entire job is to be looked INTO. Pulling it out to the screen edges
   costs nothing to the rhythm (the meta block under it keeps the gutter, so the left rail the eye
   follows is unbroken) and buys 11% more width, which is 11% more of somebody else's site.
   The rounding goes with it: a full-bleed card with 14 px corners reads as a mistake, not a frame. */
/* ⚠ HALF THE GUTTER, NOT NONE — THE FULL-BLEED VERSION LASTED ONE DEPLOY AND HE PHOTOGRAPHED IT.
   Edge to edge with `border-radius: 0` gains 11% width and destroys the RESTING state: a card that
   is not live is a BLURRED screenshot, and a blurred image with no frame, no rounding and no edge,
   running to both screen edges, does not read as a window into a site. It reads as a broken
   background — which is what «чёрные квадратные подложки» is describing.
   The rounding and the hairline are what say «this is a window». They cost 6% of the width and they
   are the difference between a preview and a blur. */
@media (max-width: 699px) {
  .work-frame {
    margin-left: calc(-0.5 * max(var(--gutter), var(--safe-l)));
    margin-right: calc(-0.5 * max(var(--gutter), var(--safe-r)));
    /* ⚠⚠ THE 9/16 PHONE CARD IS GONE, AND IT NEVER REACHED THE PAGE — READ THIS BEFORE RESTORING IT.
       The rule this replaces claimed the phone card was 9/16 so the embedded frame would be a phone
       at 390x693. It was written, but the card MEASURED 371x232 on a phone: 16/10, the desktop
       shape, because nothing here ever actually set `aspect-ratio` and the base `.work-frame` rule
       kept winning. works.js meanwhile went on rendering the site at 390 CSS px wide, so the frame
       was 390x244 — the squat landscape this file had already diagnosed once as «планшетний
       варіант, який зламаний» — and the owner photographed the result a second time.
       Two constants that must agree were written in two files. One of them moved. Nothing failed.

       Now the shape is declared HERE, in one place, and works.js derives everything from what it
       measures. 4/3 is the choice: the frame is bigger than the 16/10 strip he called «маленьке
       віконце» and it enlarges the screenshot by 20% under `cover`, while cropping only about 8% off
       each side — a desktop capture's outer margin, not its content. A squarer card would enlarge
       more and start cutting into the layout itself. */
    aspect-ratio: 4 / 3;
  }

  /* The crop this creates is spent on the SIDES, not the top: `.work-shot` is already
     `object-position: top center` in its base rule, so a taller box shows more of the first screen —
     the navigation, the headline, the hero — which is a desktop page's whole evidence. */
}


/* ══════════════════════════════════════════════════════════════════════════════════════════════
   THE PHONE PREVIEW IS ALIVE AND UNTOUCHABLE — THE TWO FACTS BELONG TOGETHER.

   ⚠ `pointer-events: none` IS LOAD-BEARING. IT IS THE ONLY THING STANDING BETWEEN A LIVE FRAME AND
   THE SCROLL TRAP A BUYER PHOTOGRAPHED ON HER OWN PHONE — «палець потрапляє у вікно і вона застрягає
   в ньому». A cross-origin iframe owns any touch that lands in it and the reader has no way to know
   the page did not break. A frame that cannot RECEIVE a touch cannot own one: every swipe over it is
   delivered to our page as though the frame were the picture it replaced.
   Delete this line and five sites become five finger traps. Nothing else here prevents that.

   ⚠ AND THE FULL-SCREEN VERSION IS GONE. Inflating the card to `inset: 0` on a second tap was
   correct in the DOM and wrong on the screen: «при дабл клике просто меня перекидывает на сайт». It
   never navigated, it just looked exactly like navigating. The second tap now unlocks the frame in
   place — the card does not move and our page keeps its own scroll.
   ══════════════════════════════════════════════════════════════════════════════════════════ */
.work[data-touch] .work-frame iframe { pointer-events: none; }
.work[data-touch].is-touchable .work-frame iframe { pointer-events: auto; }

/* Unlocked, the frame says so — otherwise the two states are pixel-identical and the reader cannot
   tell whether their swipe is about to move the page or the site inside it. */
.work[data-touch].is-touchable .work-frame {
  outline: 2px solid var(--beige);
  outline-offset: 2px;
}

/* The way back out, and the ONLY one: while the frame accepts the finger, a swipe that starts inside
   it belongs to the embedded document. Deliberate to enter, one tap to leave. */
.work-close {
  position: absolute;
  top: 12px;
  right: 12px;
  z-index: 61;
  display: none;
  align-items: center;
  min-height: 44px;
  padding: .62rem 1.1rem;
  border: 0;
  border-radius: 999px;
  background: rgba(10, 12, 16, .92);
  color: var(--ink);
  font: inherit;
  font-size: .82rem;
  cursor: pointer;
  box-shadow: 0 10px 30px -12px rgba(0, 0, 0, .9);
}
.work[data-touch].is-touchable .work-close { display: inline-flex; }
.work-close:active { transform: scale(.97); }


/* ⚠ THE ONE LINE THAT SAYS THE HEAVY CARDS WAIT TO BE ASKED. It exists only on a touch screen and
   only on the two WebGL embeds — see the note beside HEAVY in src/works.js for why those two do not
   start themselves on a phone.
   ⚠ AND IT IS A LINE OF TEXT, NOT A BUTTON. The last affordance in this position was resized three
   times and read as a giant slab every time, because it lived inside a filtered, stacked box that
   kept undoing the type scale: «эта кнопка гигантская». A caption under the frame, in the same
   monospace as the URL beside it, is at the reader's eye already and cannot grow. */
/* ⚠ THE SELECTOR IS [data-touch]. It has now been [data-tap] (dead — nothing set it once the
   heavy-card gate went) and [data-static] (dead — nothing sets it since the previews came back
   alive). Both survived as CSS that looked meaningful and matched nothing.
   The caption says what the double tap does. Its FIRST version was a pill drawn inside the frame,
   and it reproduced the mistake this comment block was written about: at 371 px the pill covered
   Зелений Гай's navigation, i.e. the evidence, to explain how to look at the evidence. Under the
   title it is at the reader's eye already and it cannot cover anything. */
/* ⚠ AND IT IS SCOPED TO [data-embed] SINCE 2026-08-30. The caption describes a gesture that only
   exists where a document can boot; on a static card it would promise a two-tap unlock that does
   nothing at all, which is worse than no caption. Two attributes, one source of truth. */
.work[data-touch][data-embed] .work-meta h3::after {
  content: 'двічі торкніться, щоб гортати сайт';
  display: block;
  margin-top: .45rem;
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: .1em;
  text-transform: uppercase;
  color: var(--beige);
  font-weight: 400;
}


/* ══════════════════════════════════════════════════════════════════════════════════════════════
   THE PORTFOLIO ON A TOUCH SCREEN — LIVE, ONE AT A TIME, AND UNTOUCHABLE UNTIL ASKED.
   src/works.js marks every card [data-touch] on a coarse pointer and then falls through to the same
   election the mouse gets. The photograph that once forced a static portfolio here still stands — a
   phone gives the touch gesture to whatever iframe it lands in — and it is answered by
   `pointer-events: none` above rather than by removing the frames.

   ⚠ THE SCREENSHOT IS SHARP HERE, AND IT IS NOW A POSTER RATHER THAN THE WHOLE STORY. The blur said
   «this is resting, it will come alive»; while nothing came alive on a phone it was only degrading
   our own evidence. The frames are live again, so the picture is what stands in until the document
   has painted — and a sharp poster is still the right one, because for the seconds it is on screen
   it is doing the same job the site will do.
   ══════════════════════════════════════════════════════════════════════════════════════════ */
.work[data-touch] .work-shot { filter: none; }
.work[data-touch] .work-frame { cursor: pointer; }

/* ⚠ THE BUTTON IS UNDER THE CARD NOW, AND THE THREE POSITIONS IT HELD BEFORE ARE THE ARGUMENT.
   Centred over the frame it lay across the hero of every preview; moved to the bottom-right corner
   it was a smaller thing covering a corner; and both were «на самом элементе», which is the part he
   objected to: «в открытый сайт кнопку убери, вниз ее отправь куда-то под».
   In the meta block it cannot overlap anything at any card size — and it sits where the reader is
   already looking, one line above the site's own address. Nothing is drawn over the preview. */
.work-open {
  display: none;
  align-items: center;
  gap: .5rem;
  /* ⚠ `width: fit-content` IS WHAT PUTS IT ON ITS OWN LINE. As a plain inline-flex it flowed into
     the same line as `.work-go`, so the pill sat jammed against «vilicor.com ↗» — a button and an
     address sharing a line, neither reading as either. A flex box that shrinks to its content is a
     block-level line of its own without needing the meta block turned into a flex column. */
  width: fit-content;
  margin-top: 1rem;
  /* ⚠ SIZED AGAINST THE CARD, NOT AGAINST A DESKTOP BUTTON. At .95rem/1.4rem the pill measured
     ~320 px inside a 371 px card — it read as a slab bolted across the preview, which is the same
     complaint («эта кнопка гигантская») this file already records about a control in this position.
     .82rem/1rem gives ~150 px, still over the 44 px thumb floor stated below. */
  padding: .62rem 1rem;
  border-radius: 999px;
  background: rgba(242, 238, 228, .94);
  color: #0A0C10;
  font-size: .82rem;
  letter-spacing: -.01em;
  text-decoration: none;
  box-shadow: 0 10px 30px -12px rgba(0, 0, 0, .9);
  /* 44 px is the smallest thing a thumb hits reliably; the padding above already clears it, and this
     states the floor so a later type change cannot quietly drop under it. */
  min-height: 44px;
  box-sizing: border-box;
}
.work[data-touch] .work-open { display: flex; }
.work-open:active { transform: scale(.97); }
