/* МОРІОН — stylesheet.
   ═══════════════════════════════════════════════════════════════════════════════════════════
   The WebGL layer is fixed behind the document; every word stays in the DOM so it is
   selectable, searchable, findable and readable by a screen reader.

   THIS FILE IS THE STRUCTURAL FIX FOR THE MEASURED CONTRAST FAILURE.
   Phase 0 measured body copy at 1.15:1 (75% scroll) and 1.61:1 (100% scroll) because the stone
   sat dead centre and the copy sat on top of it. Three mechanisms fix that, in this order of
   importance:

     1. LAYOUT (load-bearing, and the only thing allowed to be).
        Every section declares data-text-side="left|right|full", the SAME value
        choreography.js TEXT_SIDES returns — ['right','right','right','right','full','left',
        'left']. Above --bp-split the copy box is pinned to that half and physically cannot
        reach past the centre band, so the stone's half is empty by construction.
        Below --bp-split the split is abandoned rather than squeezed: one column, with the top
        --stone-band of the viewport reserved and left empty for the stone.

     2. TOKENS. Every colour that is ever applied to text carries a stated headroom: the
        brightest background luminance at which it still clears its WCAG threshold. See the
        budget below. --faint is below AA and is therefore forbidden on text — hairlines only.

     3. PLATES on fixed chrome (measurable, and unavoidable).
        The masthead, the rail, the colophon and the scroll cue float over a live GPU render
        with no layout escape: there is no arrangement of a fixed header that keeps it off a
        full-bleed canvas. Each one therefore carries a FLAT rgba background-color — flat, and
        on the element itself, so that src/probe.js composites it exactly rather than guessing.
        A gradient would be invisible to the probe and the gate would be measuring a lie.

   THE SCRIM BEHIND BODY COPY IS NOT LOAD-BEARING, AND THAT IS PROVEN, NOT PROMISED.
   .copy::before is a radial gradient on a PSEUDO-ELEMENT. probe.js walks parentElement chains
   and composites background-COLOR only, so it never sees a pseudo-element and never sees a
   gradient. Every number tools/test-contrast.mjs reports is therefore measured with the scrim
   switched off, automatically, with nothing to remember and nothing to configure. If the gate
   is green, the layout alone is green. [data-scrim="off"] on <html> remains for visual QA.
   (The mobile plate is the one exception and it is a real background-color, measured — see the
   narrow block for why a phone has no layout answer.)

   WHY html AND body HAVE NO BACKGROUND COLOUR — do not "fix" this.
   probe.js walks up from a text node and the FIRST fully opaque background-color it finds
   becomes the background it measures against; the canvas below is then ignored. An opaque
   `body { background: var(--void) }` therefore makes every text node on the page measure
   against pure black and turns the whole contrast gate into a placebo that always passes.
   The page black comes from the canvas element's own CSS background plus color-scheme: dark,
   which is visually identical and leaves the instrument honest.

   CONTRAST BUDGET — sRGB relative luminance, WCAG 2.1 ratio, computed against --void
   (L = 0.0015). "headroom" is the brightest background luminance at which the colour still
   clears 4.5:1, i.e. how much of the stone and its bloom that colour can survive.
     --ink    #eceef2   17.6:1   headroom L ≈ 0.151   headings, emphasis, display type
     --body   #c3c9d2   12.2:1   headroom L ≈ 0.090   body copy
     --dim    #9aa1ab    7.8:1   headroom L ≈ 0.040   eyebrows, labels, small mono
     --quiet  #868d97    6.1:1   headroom L ≈ 0.020   section numerals, tertiary labels
     --accent #ff8a3d    8.7:1   headroom L ≈ 0.050   links, focus ring, active state
     --faint  #6b727c    4.2:1   BELOW AA — rules, ticks, hairlines ONLY. NEVER text.

   Type: two faces, both from the system. No web font, no @font-face, no network request of any
   kind — the page loads one stylesheet and one module graph and nothing else.
   ═══════════════════════════════════════════════════════════════════════════════════════════ */

/* ── faces ─────────────────────────────────────────────────────────────────────────────────
   The same three as Vilicor, self-hosted from sites/01-gems/fonts/ — no CDN, no network request
   to a third party, 284 KB for the set. Cyrillic + Latin subsets, SIL Open Font License 1.1
   (fonts/OFL.txt). The system stack that was here before was legible and characterless; a stone
   dealer's page is a typographic object before it is a WebGL one.
     Unbounded    display — the wordmark and section titles
     Manrope      body — everything a buyer actually reads
     JetBrains Mono  data — specifications, numerals, labels
   ───────────────────────────────────────────────────────────────────────────────────────── */
/* SUBSET TO WHAT THIS SITE SETS. The three files shipped as the vendors' cyrl-latin cuts: 896
   mapped codepoints in Unbounded alone, which is every Cyrillic script — Russian, Bulgarian,
   Serbian, Macedonian — plus Latin Extended, for a page written in Ukrainian. 267 KB of fonts
   against 270 KB for the entire gzipped codebase, and woff2 is already compressed, so it is the
   one asset no server can make smaller.
   Cut to the Ukrainian alphabet (both cases), printable ASCII, and the punctuation the copy
   actually uses — 185 codepoints, generous enough that an English or Ukrainian copy edit cannot
   hit a missing glyph. The weight axis is untouched: still variable, still 400-800.
     Unbounded       187 -> 49 KB
     Manrope          41 -> 23 KB
     JetBrains Mono   39 -> 29 KB
   Regenerate with fontTools if the copy ever needs a character outside that set:
     python -m fontTools.subset <src>.woff2 --text-file=<chars>.txt --flavor=woff2        --layout-features='*' --no-hinting --desubroutinize --name-IDs='*' --notdef-outline */
@font-face {
  font-family: 'Manrope';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url('fonts/manrope-uk-latin.woff2') format('woff2');
}
@font-face {
  font-family: 'Unbounded';
  font-style: normal;
  font-weight: 400 800;
  font-display: swap;
  src: url('fonts/unbounded-uk-latin.woff2') format('woff2');
}
@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-weight: 400 600;
  font-display: swap;
  src: url('fonts/jetbrains-mono-uk-latin.woff2') format('woff2');
}

/* ── tokens ────────────────────────────────────────────────────────────────────────────── */

:root {
  /* colour */
  --void: #040507;
  --ink: #eceef2;
  --body: #c3c9d2;
  --dim: #9aa1ab;
  --quiet: #868d97;
  --faint: #6b727c;
  --edge: #262c35;
  --edge-2: #39414d;
  --accent: #ff8a3d;

  /* Plate alpha for fixed chrome. 0.9 over a blown-out white bloom composites to L ≈ 0.012,
     which leaves --dim at 6.5:1 and --body at 10:1 — i.e. the chrome stays legible against the
     worst pixel the renderer can produce, not against the average one. */
  --plate: rgba(4, 5, 7, 0.9);

  /* type — a mono for anything that reads as data or as a mark, a system sans for prose */
  --mono: 'JetBrains Mono', ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  --sans: 'Manrope', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --display: 'Unbounded', 'Manrope', system-ui, sans-serif;

  /* Type scale. Every step is a clamp, so no breakpoint makes type jump. */
  --fs-xs: 0.68rem;
  --fs-sm: 0.76rem;
  --fs-mono: 0.82rem;
  /* Sized so it clears WCAG's large-text threshold (24px) at both gating viewports — 25.6px at
     1440 and 25.5px at 1215 — because it sits in the one section the stone crosses. See the
     fire block for why that matters. */
  --fs-figure: clamp(1.05rem, 0.72rem + 1.15vw, 1.6rem);
  --fs-body: clamp(1rem, 0.93rem + 0.34vw, 1.2rem);
  --fs-lede: clamp(1.1rem, 0.98rem + 0.58vw, 1.5rem);
  --fs-h3: clamp(1.3rem, 1.1rem + 0.9vw, 2rem);
  /* RE-AUTHORED FOR UNBOUNDED, which is a much wider face than the system stack this scale was
     first drawn for. MEASURED, not guessed: at 390px the old clamp bottomed out at its 2.1rem
     minimum, 33.6px, and set uppercase Cyrillic at roughly 0.915em per glyph — so the longest
     single word on the page, ЖИТОМИРЩИНА (11 glyphs), came to 338px inside a 350px column. It fit
     by 12px, which is not a fit, it is a coincidence: one more character and #deposits' heading
     breaks the line or the box.
     The line below passes through (390, 26.2px) and (1440, 66.5px), so the DESKTOP value is within
     0.2px of what it was and only the small end moves. It is still a clamp, so nothing jumps at a
     breakpoint. 26.2px also stays above WCAG's 24px large-text threshold, which every h2 on this
     page relies on to be graded at 3:1 rather than 4.5:1 — that floor is why the minimum is
     1.6rem and not lower, and it must not be reduced. */
  --fs-h2: clamp(1.6rem, 0.69rem + 3.85vw, 4.6rem);
  --fs-h1: clamp(2.6rem, 1.1rem + 4.6vw, 6.2rem);
  --fs-display: clamp(2.8rem, 0.9rem + 6.0vw, 7.6rem);
  --fs-lines: clamp(1.05rem, 0.72rem + 1.32vw, 2rem);

  --lh-body: 1.62;
  --lh-tight: 0.96;
  --lh-display: 0.86;
  --ls-mono: 0.18em;

  /* Measure — the brief's 34–42rem band, applied as a CAP.
     Honest note on what is actually achieved: above ~1450px the half-column is 34–38rem and the
     cap is what bites. Between --bp-split and ~1450px the COLUMN is narrower than the cap
     (27–34rem) — unavoidable, because two copy halves plus a stone-safe centre band do not fit
     a 34rem measure on a 1280px screen, and widening the column is exactly the move that put
     copy under the stone in the first place. Below --bp-split there is one column and the full
     38rem is available again. */
  --measure: 38rem;

  /* THE STONE-SAFE SPLIT — 41% is a MEASURED value, do not nudge it.
     --copy-col is the width of each copy half as a share of the content box; the middle track
     takes whatever is left (18%) and nothing is ever placed in it. In viewport terms at 1440px
     the copy columns land at 0–41% and 58–100%.

     Verified against the real modules, not derived on paper: sweeping choreography.stoneStateAt
     over 4000 scroll positions x 4 landscape aspect ratios x 5 pointer corners, and projecting
     the true bounding sphere of every cut buildGeometry can produce (radius 0.525 world units),
     the stone's SILHOUETTE — not its centre — stays clear of the copy column in all of them.
     Worst clearance per section, as a share of viewport width:
        entry 9.2%   deposits 15.5%   cut 11.2%   catalogue 4.0%   parcels 11.5%   contact 22.0%
     'catalogue' at 16:9 is the binding case: the silhouette reaches 54% and the copy starts at
     58%. 4% of 1440px is 58px of daylight — real, but it is also the reason the (optional)
     scrim exists, because bloom spreads further than the silhouette does.

     WIDENING THIS IS A TRADE, NOT A FREE CHOICE. --copy-col 41% already yields only a 34.7rem
     measure at 1440, the floor of the brief's 34–42rem band, so the column cannot shrink to buy
     more clearance either. If the choreography track changes, re-run the sweep. */
  --copy-col: 41%;

  /* How far the copy plate grows past the copy itself. Paired with an equal negative margin on
     .copy, so setting this to 0 removes BOTH — see the plate block at the foot of this file. */
  --plate-pad: clamp(1.1rem, 2vw, 2.4rem);

  /* space */
  --edge-pad: clamp(1.25rem, 2.4vw, 3.5rem);   /* outer page padding */
  --gutter: clamp(1.25rem, 4.4vw, 5.5rem);     /* chrome inset: masthead, colophon */
  --dead-zone: clamp(2rem, 6vw, 9rem);         /* floor under the untouchable centre band */

  /* Viewport below which the two-half split stops being possible and the page becomes one
     column. Not a media-query value (custom properties cannot be), a stated constant that the
     media queries below repeat — keep them in sync. */
  --bp-split: 1180px;

  /* The band at the top of a narrow/portrait viewport that copy is kept out of, so the stone
     still has somewhere to be. It is 0 on a wide screen, where the stone lives in the empty
     half instead. Published so choreography.js can read it and lift the stone into it:
       getComputedStyle(document.documentElement).getPropertyValue('--stone-band') */
  --stone-band: 0svh;

  /* Scrim opacity multiplier — flipped to 0 by [data-scrim="off"] for visual QA. The contrast
     gate does not need it; see the header. */
  --scrim: 1;

  color-scheme: dark;
}

html[data-scrim="off"] { --scrim: 0; }

/* ── reset ─────────────────────────────────────────────────────────────────────────────── */

*, *::before, *::after { box-sizing: border-box; }

html {
  /* NO background-color here. See the header: an opaque background on an ancestor of the copy
     blinds the contrast probe to the canvas and the gate stops measuring anything real.
     color-scheme: dark makes the browser's own viewport fill black, and #stage covers it. */
  background-color: transparent;
  scroll-behavior: smooth;
  scrollbar-color: var(--edge-2) transparent;
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  background-color: transparent;   /* same reason as html — do not set this */
  color: var(--body);
  font-family: var(--sans);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: clip;   /* clip, not hidden: hidden would break position:fixed descendants */
}

h1, h2, h3, p, dl, dd, ol, ul, figure { margin: 0; }
ol, ul { padding: 0; list-style: none; }
button { font: inherit; color: inherit; background: none; border: 0; }
a { color: inherit; }
::selection { background: var(--accent); color: var(--void); }

/* ── layers ────────────────────────────────────────────────────────────────────────────── */

/* The canvas carries the page black on its own CSS background, which also covers the frames
   before the first draw and the case where the context has an alpha channel. */
#stage {
  position: fixed;
  inset: 0;   /* NOT width:100vw — 100vw includes the classic scrollbar and overflows by its width */
  /* A canvas is a REPLACED element: inset:0 alone does not stretch it, it keeps its intrinsic
     300×150. Left like that the stone occupies 3% of the viewport, every contrast measurement is
     taken against a page where it is not there — and at dpr > 1 it is worse than wrong, because
     canvas.width feeds back into the used layout width and the backing store runs away (measured
     at 11543×5783 ≈ 267 MB before this line existed). */
  width: 100%;
  height: 100%;
  display: block;
  z-index: 0;
  background-color: var(--void);
}

/* Film grain, drawn once as SVG turbulence and tiled. Kills the banding a near-black page shows
   on an 8-bit panel, which is the single most common tell of a cheap dark site. It sits ABOVE
   the type on purpose — grain under type reads as two layers, over it reads as one image. At
   3.5% it costs ~0.01 of relative luminance, well inside every headroom in the budget. */
#grain {
  position: fixed;
  inset: -50%;
  z-index: 20;
  pointer-events: none;
  opacity: 0.035;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='220' height='220'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  animation: grain 1.1s steps(3) infinite;
}
@keyframes grain {
  0%   { transform: translate(0, 0); }
  33%  { transform: translate(-3%, 2%); }
  66%  { transform: translate(2%, -3%); }
  100% { transform: translate(0, 0); }
}

main { position: relative; z-index: 5; }

/* ── skip link ─────────────────────────────────────────────────────────────────────────── */

.skip {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 60;
  transform: translateY(-130%);
  background: var(--ink);
  color: var(--void);
  padding: 0.8rem 1.4rem;
  font-family: var(--mono);
  font-size: var(--fs-sm);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  text-decoration: none;
  transition: transform 0.18s ease;
}
.skip:focus { transform: none; }

/* ── the wait ──────────────────────────────────────────────────────────────────────────────
   WHY THIS EXISTS. Measured 2026-08-06: the document is interactive at 12 ms and every module has
   landed by 130 ms, and then the driver spends seconds translating sixteen shader programs before
   the canvas can show anything. There is no way to make that free — this is a hand-written WebGL 2
   page whose stone shader evaluates a twenty-panel light tent up to six times per pixel — so the
   honest answer is to say so, the way every heavy WebGL site does.

   IT COVERS THE CANVAS AND NOTHING ELSE. z-index sits above #stage (1) and #grain (2) and below
   the masthead, the rail and the skip link, so the page is READ and SCROLLED while it waits: the
   copy is baked into the served HTML, so there is a real document underneath, not a splash over a
   blank page. That is also why it does not paint an opaque sheet over the whole viewport — the
   ground is the page's own background, which is what would be behind the canvas anyway.

   NOTHING ANIMATES ON A TIMER. `.boot-bar span` is scaled from src/main.js by finished programs
   over total programs. A bar that fills on a 3-second animation looks identical on a fast machine
   and lies on a slow one; this one cannot. The only timed thing here is the shimmer, and it is
   deliberately a texture rather than a progress signal. */
#boot {
  position: fixed;
  inset: 0;
  z-index: 6;
  display: grid;
  align-content: center;
  justify-items: center;
  gap: 1.15rem;
  padding: 0 var(--gutter);
  pointer-events: none;   /* the document underneath stays scrollable and clickable */
  transition: opacity 0.55s ease;
}
html[data-ready] #boot,
#boot[data-done] { opacity: 0; }

.boot-mark {
  font-family: var(--display);
  font-weight: 500;
  font-size: clamp(1.5rem, 0.9rem + 2.4vw, 3rem);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  /* Dim on purpose. This is the page telling you it is coming, not the page's title — the real
     wordmark arrives in the render a moment later and two marks at the same weight read as a
     flash of duplicated content. */
  color: color-mix(in srgb, var(--ink) 34%, transparent);
}

/* 1px, full measure, and it grows from the LEFT EDGE of the text block rather than from the
   centre: a centred bar has no relationship to the words under it. */
.boot-bar {
  width: min(22rem, 68vw);
  height: 1px;
  background: color-mix(in srgb, var(--ink) 12%, transparent);
  overflow: hidden;
}
.boot-bar span {
  display: block;
  height: 100%;
  background: var(--accent);
  transform-origin: 0 50%;
  transform: scaleX(0);
  /* Long enough to read as motion between two of the sixteen steps, short enough that the bar is
     never behind the truth by more than a step. */
  transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}

.boot-line {
  display: flex;
  gap: 0.9rem;
  align-items: baseline;
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--dim);
}
.boot-pct { color: color-mix(in srgb, var(--ink) 62%, transparent); }

/* Under the preference the bar still moves — it is the only honest report of progress — but the
   easing goes, so it steps instead of sliding. */
@media (prefers-reduced-motion: reduce) {
  #boot { transition: none; }
  .boot-bar span { transition: none; }
}

/* Visible to a screen reader, to nothing else. `clip-path` rather than `display:none`, which
   would take it out of the accessibility tree along with everything else. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ── focus ─────────────────────────────────────────────────────────────────────────────── */
/* On a black page the 1px browser default ring is invisible, and over a lit stone even an
   orange ring can wash out. Ring + opaque dark halo + soft outer glow: legible against every
   background the renderer can produce, including a white specular highlight. */

:where(a, button, [tabindex], summary):focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 2px;
  box-shadow: 0 0 0 6px rgba(4, 5, 7, 0.94), 0 0 0 9px rgba(255, 138, 61, 0.3);
}
.skip:focus { outline: 2px solid var(--accent); outline-offset: -6px; box-shadow: none; }

/* ── masthead ──────────────────────────────────────────────────────────────────────────── */
/* Fixed chrome over a live render. The flat plate is the measurable half; the ::after gradient
   only softens its bottom edge and is deliberately on a pseudo-element, where it cannot be
   mistaken by the probe for something load-bearing. */

.masthead {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 40;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1.5rem;
  padding: clamp(0.9rem, 2.2vw, 1.7rem) var(--gutter);
  background-color: var(--plate);
  pointer-events: none;
}
.masthead > * { pointer-events: auto; }
.masthead::after {
  content: "";
  position: absolute;
  inset: 100% 0 auto 0;
  height: 4.5rem;
  pointer-events: none;
  background: linear-gradient(to bottom, rgba(4, 5, 7, 0.9), rgba(4, 5, 7, 0));
}

.mark {
  display: flex;
  align-items: baseline;
  gap: 0.7rem;
  text-decoration: none;
  font-family: var(--mono);
  font-size: var(--fs-sm);
  letter-spacing: var(--ls-mono);
  text-transform: uppercase;
}
.mark-latin { color: var(--ink); }
.mark-uk { color: var(--dim); }

.mark-cta {
  font-family: var(--mono);
  font-size: var(--fs-sm);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--dim);
  text-decoration: none;
  border-bottom: 1px solid var(--edge-2);
  padding-bottom: 0.2rem;
  transition: color 0.2s ease, border-color 0.2s ease;
}
.mark-cta:hover { color: var(--accent); border-color: var(--accent); }

/* ── section rail ──────────────────────────────────────────────────────────────────────── */
/* Sits in the outer padding on the left. For sections 00–03 the stone is on the LEFT, so this
   is the one piece of chrome that shares a side with the stone — hence the plate on the list
   itself rather than on the nav, which would paint a full-height column. */

/* The rail is fixed and the copy column moves between halves, so a rail pinned to one side is
   guaranteed to sit on top of the copy in whichever sections put it there — measured: it occluded
   two paragraphs of #parcels, the section carrying the email address. It now moves to whichever
   half the copy is NOT in. main.js writes data-rail-side on <html> from the active section's
   textSide, so the two decisions can no longer disagree. */
.rail {
  position: fixed;
  left: max(0.5rem, env(safe-area-inset-left));
  right: auto;
  top: 50%;
  translate: 0 -50%;
  z-index: 40;
  transition: left 0.5s cubic-bezier(0.22, 1, 0.36, 1), right 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}

html[data-rail-side='right'] .rail {
  left: auto;
  right: max(0.5rem, env(safe-area-inset-right));
}

@media (prefers-reduced-motion: reduce) {
  .rail { transition: none; }
}
.rail ol {
  display: flex;
  flex-direction: column;
  gap: 0.95rem;
  padding: 0.85rem 0.7rem;
  background-color: var(--plate);
  border: 1px solid var(--edge);
}
.rail a {
  display: flex;
  align-items: center;
  gap: 0.65rem;
  text-decoration: none;
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: 0.16em;
  color: var(--dim);
  transition: color 0.22s ease;
}
.rail a::before {
  content: "";
  width: 0.75rem;
  height: 1px;
  background: var(--faint);
  transition: width 0.22s ease, background 0.22s ease;
}
.rail-label {
  color: var(--dim);
  white-space: nowrap;
  opacity: 0;
  translate: -0.35rem 0;
  transition: opacity 0.22s ease, translate 0.22s ease;
}
.rail a:hover, .rail a:focus-visible { color: var(--ink); }
.rail a:hover::before, .rail a:focus-visible::before { width: 1.5rem; background: var(--ink); }
.rail a:hover .rail-label, .rail a:focus-visible .rail-label { opacity: 1; translate: 0 0; }
.rail a[aria-current="true"] { color: var(--accent); }
.rail a[aria-current="true"]::before { background: var(--accent); width: 1.5rem; }

/* ── the section grid ──────────────────────────────────────────────────────────────────── */
/* Three tracks: copy-left | untouchable centre | copy-right. Both copy tracks are --copy-col
   wide and the centre takes the remainder; nothing is ever placed in the centre. A copy box
   pinned to one half therefore cannot cross 41% (left) or reach below 59% (right) of the
   content box, which is the guarantee choreography.js relies on: put the stone's projected
   centre anywhere past 0.258 NDC on the opposite side and it is never behind a paragraph. */

.s {
  position: relative;
  min-height: 100svh;
  display: grid;
  grid-template-columns:
    [full-start copy-l-start] var(--copy-col)
    [copy-l-end] minmax(var(--dead-zone), 1fr)
    [copy-r-start] var(--copy-col) [copy-r-end full-end];
  align-content: center;
  padding: clamp(6rem, 12vh, 9rem) var(--edge-pad) clamp(5rem, 10vh, 8rem);
  /* The scrim below is inset -3.5rem horizontally, which pushes it past the section on the side
     the copy is pinned to and made the document 21px wider than the viewport. Clip it here
     rather than shrinking it: by 80% of its radius the gradient is already at zero alpha, so
     the clipped part is invisible, and clipping is the only fix that cannot be defeated by a
     future change to --edge-pad. */
  overflow-x: clip;
}

.s[data-text-side="left"]  > .copy { grid-column: copy-l-start / copy-l-end; }
.s[data-text-side="right"] > .copy { grid-column: copy-r-start / copy-r-end; justify-self: end; }
.s[data-text-side="full"]  > .copy { grid-column: full-start / full-end; }

.copy {
  position: relative;
  width: 100%;
  max-width: var(--measure);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
}

/* The scrim. Anchored to the copy box's OUTER edge so it never bleeds toward the stone, and it
   fades out well before the centre band. Peak 0.34 — enough to absorb bloom spill, not enough
   to be doing the layout's job. On a pseudo-element, which is what makes it provably
   non-load-bearing: probe.js cannot see it, so every gate number is measured without it. */
.copy::before {
  content: "";
  position: absolute;
  inset: -2.5rem -3.5rem;
  z-index: -1;
  pointer-events: none;
  opacity: var(--scrim);
  background: radial-gradient(
    115% 105% at var(--scrim-origin, 0%) 50%,
    rgba(4, 5, 7, 0.34),
    rgba(4, 5, 7, 0.2) 52%,
    rgba(4, 5, 7, 0) 80%
  );
}
.s[data-text-side="right"] > .copy { --scrim-origin: 100%; }

/* Vertical rhythm: the entry screen sits low so the wordmark lands where the eye already is
   after the masthead, and so the scroll cue has somewhere to live. */
.s-entry { align-content: end; }

/* ── typography ────────────────────────────────────────────────────────────────────────── */

.eyebrow {
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: var(--ls-mono);
  text-transform: uppercase;
  color: var(--dim);
  display: flex;
  align-items: center;
  gap: 0.85rem;
  margin-bottom: 1.5rem;
  line-height: 1.4;
}
.eyebrow .num { color: var(--quiet); font-variant-numeric: tabular-nums; }
.eyebrow--center { justify-content: center; }
.rule {
  display: inline-block;
  flex: 0 0 auto;
  width: clamp(1.5rem, 4vw, 3.5rem);
  height: 1px;
  background: var(--edge-2);
}

.wordmark {
  font-family: var(--display);
  font-weight: 500;
  font-size: var(--fs-h1);
  line-height: var(--lh-display);
  letter-spacing: -0.045em;
  text-transform: uppercase;
  color: var(--ink);
  margin-bottom: 1.5rem;
  text-wrap: balance;
  /* A LAST-RESORT GUARD, not a style. The words in these headings come from content-uk.js and can
     be re-authored by someone who is not looking at a 360px screen; Unbounded set in uppercase is
     wide enough that one long Ukrainian compound overflows a phone column and pushes the document
     sideways. `break-word` breaks a word ONLY when it cannot fit on a line by itself, so it is
     inert for every heading currently on the page and cannot change desktop layout at all. */
  overflow-wrap: break-word;
}

h2 {
  font-family: var(--display);
  font-weight: 500;
  font-size: var(--fs-h2);
  line-height: var(--lh-tight);
  letter-spacing: -0.035em;
  text-transform: uppercase;
  color: var(--ink);
  margin-bottom: 1.2rem;
  text-wrap: balance;
  overflow-wrap: break-word;   /* see .wordmark */
}

.tagline {
  font-family: var(--mono);
  font-size: var(--fs-mono);
  letter-spacing: 0.1em;
  color: var(--accent);
  margin-bottom: 1.6rem;
}
.tagline:empty { display: none; }

/* Body copy. `.ledes` is the container main.js fills with <p>. The opening paragraph is set
   larger and brighter, which is both a hierarchy decision and the cheapest contrast win on the
   page: the line most likely to be read is the one given the most headroom. */
.ledes { display: flex; flex-direction: column; gap: 1rem; color: var(--body); }
.ledes > p {
  max-width: var(--measure);
  text-wrap: pretty;
  hyphens: auto;   /* correct only because <html lang="uk"> is set */
}
.ledes > p:first-child {
  font-size: var(--fs-lede);
  line-height: 1.45;
  color: var(--ink);
}
.ledes:not(:empty) { margin-bottom: 2rem; }

/* ── data blocks ───────────────────────────────────────────────────────────────────────── */

.meta, .spec, .readout {
  width: 100%;
  font-family: var(--mono);
  font-size: var(--fs-mono);
  border-top: 1px solid var(--edge);
}
.meta > div, .spec > div, .readout > div {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 2rem;
  padding: 0.7rem 0;
  border-bottom: 1px solid var(--edge);
}
.meta dt, .spec dt, .readout dt {
  color: var(--dim);
  letter-spacing: 0.05em;
  flex: 0 1 auto;
}
.meta dd, .spec dd, .readout dd {
  color: var(--body);
  text-align: right;
  flex: 1 1 auto;
  min-width: 0;
}
.meta:empty, .spec:empty, .readout:empty { display: none; }
.meta:not(:empty) { margin-bottom: 1.8rem; }

/* Wide variant: the value is a sentence or a list, not a figure, so let it wrap under the key
   instead of shredding it into a narrow right-aligned column. */
.meta--wide > div { flex-wrap: wrap; gap: 0.35rem 2rem; }
.meta--wide dd { text-align: left; flex-basis: 100%; }

.tabular { font-variant-numeric: tabular-nums; }

/* The live read-out in 02: what the geometry on screen actually is. Brighter than the
   reference table below it, because it is the thing that changes. */
.readout { margin-bottom: 1.8rem; }
.readout dd { color: var(--ink); font-size: 1.05em; }

/* ── 03 catalogue ──────────────────────────────────────────────────────────────────────── */

.species {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-bottom: 1.6rem;
}
.species-btn {
  cursor: pointer;
  font-family: var(--mono);
  font-size: var(--fs-sm);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--body);
  padding: 0.5rem 0.85rem;
  border: 1px solid var(--edge);
  /* Flat, opaque enough to be measured and to hold up if bloom reaches this far into the copy
     half. A translucent chip at 0.55 composited to 1.7:1 against a white highlight. */
  background-color: var(--plate);
  transition: color 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
}
.species-btn:hover { color: var(--ink); border-color: var(--edge-2); }
/* The pressed state was an INVERSION — near-black text on a near-white chip — and an inversion is
   the one thing a two-phase instrument cannot measure safely: record the colour in one state,
   photograph the pixels in the other, and you get the pressed pill's near-black text against the
   unpressed pill's near-black chip. That read 1.19:1 for an element that is 17.6:1 in both of its
   real states. Freezing the DOM during measurement fixed most of it and the last viewport still
   caught it, so the ambiguity goes instead of the symptom: pressed is now light-on-dark like
   every other pill, distinguished by the accent border and rule rather than by swapping ends of
   the scale. Same affordance, no state in which the colours cross over. */
.species-btn[aria-pressed="true"] {
  color: var(--ink);
  background-color: var(--plate);
  border-color: var(--accent);
  box-shadow: inset 0 -2px 0 0 var(--accent);
}

.stone-panel { width: 100%; }
.stone-name {
  font-family: var(--display);
  font-weight: 500;
  font-size: var(--fs-h3);
  line-height: 1.1;
  letter-spacing: -0.01em;
  color: var(--ink);
  margin-bottom: 0.9rem;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.7rem;
}
.stone-latin {
  font-size: var(--fs-sm);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--dim);
}

/* Per-species prose: what it is, how it behaves on the wheel, what it is for. Not .ledes —
   there is no lede paragraph here, the three read as equals. */
.stone-copy {
  display: flex;
  flex-direction: column;
  gap: 0.9rem;
  color: var(--body);
  margin-bottom: 1.6rem;
}
.stone-copy > p {
  max-width: var(--measure);
  text-wrap: pretty;
  hyphens: auto;
}
.stone-copy > p:empty { display: none; }

.origin {
  font-family: var(--mono);
  font-size: var(--fs-sm);
  color: var(--body);
  letter-spacing: 0.04em;
  margin-top: 1rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
}
.origin-key {
  color: var(--quiet);
  text-transform: uppercase;
  letter-spacing: 0.16em;
}

/* ── 04 fire · display type only ───────────────────────────────────────────────────────── */
/* Four rows: a top band, an empty middle, the display lines, and the dispersion figures. This
   is the one section allowed data-text-side="full", it is the one section that carries no body
   copy, and those are the same fact. It is also choreography's pivot: the copy side can only
   change across a section that imposes no side constraint, and this is the only one.

   BE HONEST ABOUT WHAT THE EMPTY ROW DOES. Measured: at the 'fire' keyframe the stone's
   silhouette is roughly 0.62 NDC tall — about 62% of the viewport — while the middle row is
   28svh. The band is where the stone's CENTRE sits; it is not, and cannot be, a box that
   contains the stone. So the type here WILL be crossed, and the defence is not the layout:
     - everything in this section is set in --ink (17.6:1) or --body (12.2:1), and
     - everything in this section is sized to clear WCAG's large-text threshold at both gating
       viewports, so it is held to 3:1 rather than 4.5:1.
   At 3:1, --ink survives a background up to L ≈ 0.251 — a mid-to-bright grey. That is the
   number doing the work, not the empty row. The empty row is what stops the stone's centre,
   where it is brightest, from landing on a line. */

.s-fire {
  grid-template-columns: minmax(0, 1fr);
  grid-template-rows: auto minmax(28svh, 1fr) auto auto;
  align-content: stretch;
  justify-items: center;
  text-align: center;
  row-gap: clamp(1.1rem, 3vh, 2.4rem);
  /* Tighter than a copy section: the four bands have to fit inside one viewport, or the middle
     row stops being where the viewport's centre is and the stone drifts onto the type. */
  padding-block: clamp(4rem, 8vh, 6.5rem) clamp(3rem, 6vh, 5rem);
}
.fire-top { display: flex; flex-direction: column; align-items: center; }
.fire-void { min-height: 0; }

.display {
  font-family: var(--mono);
  font-weight: 500;
  font-size: var(--fs-display);
  line-height: var(--lh-display);
  letter-spacing: -0.05em;
  text-transform: uppercase;
  color: var(--ink);
  margin: 0;
  text-wrap: balance;
}

.display-lines {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  align-items: center;
  font-family: var(--mono);
  font-size: var(--fs-lines);
  line-height: 1.25;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--ink);
}
.display-lines > p { max-width: 44rem; text-wrap: balance; }
.display-lines:empty { display: none; }

/* Dispersion figures, one per species. Data set at display size, in --ink and --body, so it
   clears its threshold with room and is not body copy by any reading. A horizontal strip —
   never a column standing beside the stone. */
.figures {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.45rem 2.2rem;
  width: min(100%, 62rem);
  padding-top: 1.1rem;
  border-top: 1px solid var(--edge);
  font-family: var(--mono);
  font-size: var(--fs-figure);
  letter-spacing: 0.04em;
}
.figures > div { display: flex; align-items: baseline; gap: 0.55rem; }
.figures dt { color: var(--body); text-transform: uppercase; letter-spacing: 0.1em; }
.figures dd { color: var(--ink); font-variant-numeric: tabular-nums; }
.figures:empty { display: none; }

/* ── misc ──────────────────────────────────────────────────────────────────────────────── */

.cta-wrap { margin-top: 0.4rem; }
.cta-wrap:empty { display: none; }
.cta-wrap a, .cta {
  display: inline-block;
  font-family: var(--mono);
  font-size: var(--fs-sm);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink);
  text-decoration: none;
  padding: 0.75rem 1.3rem;
  border: 1px solid var(--edge-2);
  background-color: var(--plate);
  transition: color 0.2s ease, border-color 0.2s ease, background-color 0.2s ease;
}
.cta-wrap a:hover, .cta:hover {
  color: var(--void);
  background-color: var(--accent);
  border-color: var(--accent);
}

/* The cue lives on the COPY side, not the left edge: in 00 the stone is on the left, and a
   left-anchored cue would be the one element deliberately placed under it. */
.scroll-cue {
  position: absolute;
  bottom: clamp(1.5rem, 4vh, 3rem);
  padding: 0.5rem 0.7rem;
  background-color: var(--plate);
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: 0.3em;
  text-transform: uppercase;
  color: var(--dim);
}
.s[data-text-side="left"] .scroll-cue  { left: var(--edge-pad); }
.s[data-text-side="right"] .scroll-cue { right: var(--edge-pad); }
.scroll-cue::after {
  content: "";
  display: block;
  width: 1px;
  height: 2.2rem;
  margin: 0.7rem auto 0;
  background: linear-gradient(to bottom, var(--edge-2), transparent);
  animation: cue 2.4s ease-in-out infinite;
  transform-origin: top;
}
@keyframes cue {
  0%, 100% { transform: scaleY(0.35); opacity: 0.4; }
  50%      { transform: scaleY(1);    opacity: 1; }
}

.colophon {
  position: relative;
  z-index: 5;
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: baseline;
  gap: 0.6rem 2rem;
  padding: 2.5rem var(--gutter) clamp(2rem, 5vh, 3.5rem);
  border-top: 1px solid var(--edge);
  background-color: var(--plate);
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--dim);
}
.colophon-line { display: flex; gap: 0.6rem; color: var(--ink); }
.colophon-sep { color: var(--quiet); }
.colophon-note { color: var(--dim); letter-spacing: 0.1em; text-transform: none; }

.noscript-note {
  position: relative;
  z-index: 30;
  padding: 1rem var(--gutter) 3rem;
  font-family: var(--mono);
  font-size: var(--fs-sm);
  color: var(--ink);
}

/* ── typography motion (src/textfx.js) ─────────────────────────────────────────────────────
   installTextFx() rebuilds every [data-split] element as
       <span class="w"><span class="g">М</span><span class="g">О</span>…</span>" "<span class="w">…
   — one wrapper per WORD, one span per grapheme, and the inter-word spaces left as plain text
   nodes between the wrappers. This block is everything the stylesheet has to assert about it,
   and each rule is load-bearing:

   .w  white-space: nowrap. WITHOUT THIS THE SPLIT BREAKS WORDS. Two adjacent inline-block
       glyphs are a legal line-break opportunity, so a split heading would happily wrap
       "Житомирщина" as "Житомир|щина". nowrap on the word confines every break to a real
       space, which is also why the spaces are outside .w and not inside it.
       .w is deliberately NOT display: inline-block — it does not need to be, and leaving it
       inline keeps the word out of the baseline arithmetic entirely.

   .g  inline-block, because a plain inline cannot be transformed. That does not move anything
       here: all seven [data-split] elements are set in --mono, which has no kerning and no
       ligatures, and an inline-block whose line-height is inherited puts its baseline in the
       same place as the parent's strut. Measured, before and after the split, at three
       viewports: identical widths, identical line counts, identical heights.

   transform-origin sits slightly below centre. A shard that rotates about its optical centre
   rather than its box centre reads as a chip off a stone; about the box centre it reads as a
   letter on a turntable. */

/* ⚠ CORRECTION, MEASURED 2026-08-06. This rule read `white-space: normal; overflow-wrap: anywhere`
   for a while, on the stated grounds that «the line breaker uses those opportunities ONLY when the
   word does not fit — a break at a space is always preferred». THAT IS FALSE, and the page showed
   it: at 390x844 the parcels heading rendered as «СИРОВИНА / , ПРЕФОРМ / И, ОГРАНКА».
   Counted at 390 px over the headings that have more than one word:
     normal + overflow-wrap: anywhere      2 of 3 words broken mid-word, on deposits and on parcels
     normal + overflow-wrap: break-word    2 of 3 — IDENTICAL. The breaks are not overflow-wrap
                                           breaks at all: two adjacent inline-block glyphs are an
                                           ORDINARY soft-wrap opportunity, so `normal` alone is
                                           already enough to shred a word and no overflow-wrap
                                           value can take that back.
     nowrap                                0 broken, at every heading
   So the word stays atomic, which is what the block above says and why. The one word that is
   wider than its column is handled where it belongs — see the cap on #deposits h2 — instead of by
   letting every heading in the document break mid-word. */
.fx-split .w { white-space: nowrap; }

.fx-split .g {
  display: inline-block;
  transform-origin: 50% 58%;

  /* THE PADDING/MARGIN PAIR IS NOT COSMETIC. It exists because of a measured failure.
     Every heading on this page is set with line-height below 1 (0.86 display, 0.96 h2), so the
     element's box is SHORTER than the text's own font box — about 0.125em shorter, split above
     and below. tools/test-contrast.mjs paints its reference slab into the element's background
     box and samples it inside the TEXT box, and it tolerates that mismatch by discarding the
     outer 20% of each line.
     That tolerance runs out at the viewport edge. A heading clipped to a sliver by the top of
     the screen can have nothing but leading left in the sampled band, and the slab is not in the
     leading — so the reference colour is never photographed and the record reads about 1:1
     against a black page.
     padding-block grows the BACKGROUND box until it covers the glyph's whole font box, so the
     slab is always where the instrument looks; the equal negative margin-block leaves the MARGIN
     box, which is what a line box is built from, exactly the size it was.
     MEASURED, A/B at 1440x900 with the pair forced off and on at the same scroll stop: three
     glyphs of #contact-title went from 1.03:1 with a best slab drift of 229/255 (the slab was
     not photographed at all) to 1.17:1 at 212/255. Layout drift both ways: 0px wide, 0px tall,
     0 lines, glyphs in the same places.
     The residual 1.17:1 is NOT this, and is not the split either: at 100% scroll that heading has
     passed under the fixed masthead, whose ::after paints linear-gradient(rgba(4,5,7,0.9), 0) on
     top of it. h2#contact-title reports the same 1.17:1 unsplit, at the same stop, for the same
     reason. That one belongs to whoever owns the masthead's z-order, not here. */
  padding-block: 0.3em;
  margin-block: -0.3em;
}

/* will-change is applied ONLY while an element is actually animating. textfx toggles .fx-live.
   Leaving it on ~150 glyph spans permanently promotes ~150 compositor layers for a page that is
   at rest almost all the time, which costs GPU memory on exactly the phones that can least
   afford it. `filter` is not listed because nothing here ever sets one — see the note on blur in
   the header of src/textfx.js. */
.fx-live .g { will-change: transform, opacity, clip-path; }

/* Fallback for the case where the module never runs at all: a [data-split] element that was
   somehow split and then abandoned must still be plain, opaque text rather than a blank line. */
[data-split] .g { display: inline-block; }

/* ── section transitions: nothing cuts ─────────────────────────────────────────────────────
   src/textfx.js publishes, every frame, a set of CONTINUOUS functions of timeline.blend:

     on <html>   --fx-u         0..6, the continuous section walk (timeline guarantees C¹)
                 --fx-blend     0..1 within the current pair
                 --fx-vel       -1..1, damped normalised scroll velocity
                 --fx-side      -1 copy-left .. +1 copy-right, interpolated across the pair
                 --fx-progress  0..1 over the whole document
                 --rail-pitch / --rail-item / --rail-pad   measured rail geometry, px
     on each .s  --sec-in       1 at that section's own hold point, 0 one section away

   EVERY FALLBACK IN THIS BLOCK IS THE RESTING VALUE. `var(--sec-in, 1)` is 1, not 0: with the
   module absent, or before its first frame, the page must look like the page at rest and not
   like a page waiting to be told it has arrived. That is also what keeps the no-JavaScript and
   .no-webgl paths correct without a single extra rule.

   NOTHING HERE TOUCHES A TEXT COLOUR OR A TEXT OPACITY. Fading a label to signal "this section
   is not the active one" is the single easiest way to hand back the contrast the layout fix
   bought, so the section-presence signal is carried by a hairline, a scrim and a transform —
   none of which the WCAG measurement is entitled to care about. */

/* A --sec-in glide on .copy was built here and then REMOVED, and the reason is worth keeping.
   A translate is contrast-neutral in principle — probe.js measures the box where it currently
   is and paintMode paints the slab into that same box, so the two move together. It is not
   contrast-neutral in practice, because moving a column of copy 12px changes WHICH PART OF THE
   STONE is behind it. Measured, A/B at the same scroll stops: five body-copy nodes lost between
   0.3 and 0.8 of a point, and one that was already marginal went under. The effect was worth
   about nothing and it cost real readability, so it lost. Section continuity is carried below by
   things the copy does not sit on: a hairline, a pseudo-element scrim, and the rail marker. */

/* The eyebrow hairline retracts between sections and draws itself back out as the section
   arrives. scaleX, not width: width is layout, and this runs every frame. */
.eyebrow .rule {
  transform: scaleX(calc(0.3 + 0.7 * var(--sec-in, 1)));
  transform-origin: left center;
}
.eyebrow--center .rule { transform-origin: center; }

/* The scrim breathes with the section instead of being a constant. It is on a pseudo-element,
   so it is invisible to the contrast gate by construction — this cannot buy or cost a single
   point of the measurement, which is exactly why it is allowed to move. */
.copy::before { opacity: calc(var(--scrim) * (0.55 + 0.45 * var(--sec-in, 1))); }

/* The rail marker. Today the rail's only section signal is aria-current, which main.js flips on
   a boundary — a cut, and the one cut on the page that the eye is guaranteed to be looking at
   while it happens. This is the same information as a continuous position: an accent tick that
   slides along the rail with --fx-u and passes through the in-between states instead of
   teleporting. It sits in the list's own left padding, left of the dashes, so it never lands on
   a numeral or a label. The height fallback is 0, so before textfx has published the measured
   rail geometry the marker has no box at all — a marker parked on item zero forever is worse
   than no marker. */
.rail ol { position: relative; }
.rail ol::after {
  content: "";
  position: absolute;
  left: 0;
  top: var(--rail-pad, 0px);
  width: 2px;
  height: var(--rail-item, 0px);
  background: var(--accent);
  translate: 0 calc(var(--fx-u, 0) * var(--rail-pitch, 0px));
  pointer-events: none;
}

/* ── narrow and portrait ───────────────────────────────────────────────────────────────── */
/* Below --bp-split (1180px) a half column is under 27rem, which is too tight for prose, so the
   split is ABANDONED rather than squeezed. One column, and the top --stone-band of every
   section is left empty for the stone.
   A phone has no empty half to give away, so this is the one place the layout cannot solve the
   problem on its own and a real, measured backdrop goes behind the copy: a flat
   background-color on the element itself, which probe.js composites exactly. At 0.9 over a
   blown-out highlight it leaves --body at 10:1 and --dim at 6.5:1. It is stated here rather
   than hidden because a plate that nobody admits to is how the desktop layout got broken. */

@media (max-width: 1180px), (orientation: portrait) {
  :root {
    --stone-band: 40svh;

    /* ── EVERY PLATE ON A PHONE IS OPAQUE, and that is a readability decision before it is a
       look. At 0.88–0.9 the render composites through the plate, and MEASURED at 390x844 that
       is not atmosphere, it is dirt: the stone's bloom reads as a grey-brown smear behind a
       350px column of body copy (see #cut and #deposits at any mid-section scroll). The same
       0.1 leak is why a section heading is legible THROUGH the fixed masthead — h2 is #eceef2,
       and 10% of #eceef2 over black is #181a1c, which photographs as readable ghost type
       colliding with the wordmark. On a wide screen the leak buys depth because there is a
       lit half of the screen to be deep about; on a phone the copy is edge-to-edge and there
       is nothing behind it but noise.
       This can only move the contrast gate upward — every text node inside a plate now
       measures against #040507 exactly. Re-measured all the same: npm run test:contrast. */
    --plate: #040507;
    --plate-alpha: 1;
    --plate-alpha-fire: 1;
  }

  /* The plate goes edge to edge and the text is inset by the padding, rather than the copy box
     keeping the section's padding and the plate reaching out past it with a negative margin —
     that version was clipped on one side by max-width and left a 40px asymmetry on a phone. */
  .s {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: var(--stone-band) auto;
    align-content: start;
    padding-inline: 0;
    /* THE 3.5rem THAT USED TO BE HERE WAS 76px OF SPACE THE STONE CANNOT REACH, SEVEN TIMES.
       The empty region above a section's copy is `padding-top + --stone-band`. It was
       20 + 287 + 56 = 363px at 390x844, i.e. 0.430 of the viewport. choreography.js does not
       use all of that and cannot: it pins the portrait stone inside a FIXED NDC interval,
       BAND_TOP_NDC 0.9 to BAND_BOTTOM_NDC 0.4 — viewport y 42px to 253px at this height. So
       110px of the reserved band was guaranteed to be empty black, every section, and it read
       as the page having simply stopped.
       Dropping the 3.5rem leaves 20 + 287 = 307px, 0.364 of the viewport: still comfortably
       above choreography's 0.30 floor (its own note gives 0.409/0.478/0.447 as the measured
       values it chose 0.30 to sit under), so the containment proof in §4 of that module is
       untouched, and it takes 56 x 7 = 392px out of the document.
       #fire keeps the old padding below, because #fire is the one section with CONTENT in
       grid row 1 and that content has a fixed masthead to clear. */
    padding-top: var(--edge-pad);
    /* The base rule's bottom padding is clamp(5rem, 10vh, 8rem) — 84px at 844svh. It is dead
       space, and it is not the stone's: the NEXT section already reserves --stone-band plus its
       own 76px top padding, so the measured gap between one plate and the next was 447px, over
       half a phone screen of nothing, seven times down the document. The band is the window the
       stone gets; this padding was only widening the frame around it. */
    padding-bottom: clamp(1.25rem, 3vh, 2.5rem);
  }
  .s > .copy,
  .s[data-text-side="left"] > .copy,
  .s[data-text-side="right"] > .copy,
  .s[data-text-side="full"] > .copy {
    grid-column: 1 / -1;
    grid-row: 2;
    justify-self: stretch;
    width: 100%;
    max-width: none;
    --plate-pad: 0px;   /* cancels the outward-growing plate AND its negative margin together */
    /* env() is 0 in every desktop browser and in the harness, so these are inert here and
       correct on hardware. The document declares viewport-fit=cover, which is what licenses the
       render to run under the notch and the home indicator — and what makes it this
       stylesheet's job to keep TYPE out from under them. Left/right matter in landscape, where
       a rounded corner and a notch both eat into the copy column. */
    padding: 1.5rem calc(var(--edge-pad) + env(safe-area-inset-right))
             2rem calc(var(--edge-pad) + env(safe-area-inset-left));
    background-color: var(--plate);
    /* NO border-top. The hairline was an honest declaration of an edge that had a 0.45 alpha
       step in it; with the fade below now reaching full opacity exactly at this edge there is
       no step left to declare, and a 1px rule across a phone screen reads as a UI panel rather
       than as a plate laid over a scene. */
  }

  /* The scrim is repurposed rather than dropped. On a wide screen it is a radial wash on the
     copy's outer edge; here it is a vertical fade above and below the plate. z-index: -1 puts it
     behind .copy's own background — .copy sets no stacking context — so the middle of the
     gradient is hidden by the opaque plate and only the two fades are ever visible.
     IT NOW RAMPS TO FULLY OPAQUE, not to 0.55. Against an opaque plate a 0.55 fade is not a
     fade, it is a 0.45 discontinuity sitting one pixel above the plate's top edge — the seam
     this was supposed to remove. Reaching 1.0 at the edge makes the two continuous.
     Contrast-neutral by construction and not by argument: the ramp occupies the 3rem ABOVE the
     plate and the plate's own 1.5rem of padding, and there is no text in either. It is also
     strictly darker than what it replaces, everywhere. */
  .copy::before {
    display: block;
    inset: -3rem 0 -2.5rem 0;
    background: linear-gradient(
      to bottom,
      rgba(4, 5, 7, 0) 0,
      rgba(4, 5, 7, 1) 3rem,
      rgba(4, 5, 7, 1) calc(100% - 2.5rem),
      rgba(4, 5, 7, 0) 100%
    );
  }
  /* ...and it stops breathing with --sec-in. The section-presence glide multiplies this
     pseudo-element's OPACITY by 0.55..1.0, which on a wide screen modulates a wash nobody can
     name. Here it would modulate the ramp's peak alpha, so the seam this block just removed
     would reappear for the length of every section transition — the one moment the eye is
     guaranteed to be watching that edge. The section signal on a phone is carried by the
     eyebrow hairline, which is still live. */
  .copy::before { opacity: var(--scrim); }

  .s-entry { align-content: start; }

  /* fire keeps its four bands; the middle one becomes the reserved stone band.
     align-content was `stretch`, which shares the section's leftover height out among the three
     AUTO rows — so on a phone the slack landed as a gap under the title instead of around the
     stone, and #fire measured 956px with roughly 400px of blank between ВОГОНЬ and the first
     display line. Give the slack to the void row, which is the row that is supposed to be empty,
     and centre what is left. */
  .s-fire {
    grid-template-rows: auto minmax(var(--stone-band), 1fr) auto auto;
    align-content: center;
    /* The one section whose grid row 1 holds CONTENT rather than the reserved band, so it is the
       one section that still has to clear the fixed masthead. Everywhere else this padding was
       buying nothing but black — see the note on .s above. */
    padding-top: calc(var(--edge-pad) + 3.5rem);
    /* One window, not three. The row gap applies between EVERY pair of rows, so #fire
       photographed as four disconnected plates separated by translucent stripes of raw render:
       a band around the eyebrow and title, a stripe, the display lines, a stripe, the figures.
       With the gap at 0 the three text rows meet edge to edge and the only opening left in the
       section is the void row, which is the row that is supposed to be an opening. */
    row-gap: 0;
  }
  .fire-top { width: 100%; }
  .display-lines, .figures {
    width: 100%;
    padding: 1.25rem var(--edge-pad);
    background-color: var(--plate);
  }

  /* The figures strip is a horizontal flex row on a wide screen. Wrapped into a 390px column it
     produced two ragged pairs per line and then a key whose value ran on alone to the right —
     the one block on the page that looked broken rather than dense. One row per figure, keyed
     left and valued right, which is the same shape as every other .meta table here. */
  .figures {
    display: flex;
    flex-direction: column;
    gap: 0;
    justify-content: flex-start;
  }
  .figures > div {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    gap: 1rem;
    width: 100%;
    padding: 0.5rem 0;
    border-bottom: 1px solid var(--edge);
  }
  .figures > div:last-child { border-bottom: 0; }
  .figures dd { text-align: right; }

  .rail { opacity: 0; pointer-events: none; }
  .rail:focus-within { opacity: 1; pointer-events: auto; }
  /* The rail is invisible here but it is still the keyboard skip-list, and :focus-within brings
     it back on screen. A row that comes back at 19px tall is a control nobody can hit. It only
     grows once it is actually visible, so this costs no layout on the resting page. */
  .rail:focus-within a { min-height: 40px; }

  .scroll-cue { display: none; }
  .meta > div, .spec > div, .readout > div { gap: 1rem; }

  /* ── the notch and the home indicator ──────────────────────────────────────────────────────
     <meta name="viewport" content="... viewport-fit=cover"> is what lets the canvas run into the
     rounded corners, and it is therefore also what puts the wordmark under the notch and the
     colophon under the home indicator unless this is said. env() resolves to 0 on every desktop
     browser and in the capture harness, so these are inert in every measurement in this repo and
     correct on hardware. NOT VERIFIED ON A DEVICE — there is no phone attached to this machine;
     it is the documented mechanism, applied where the fixed chrome is. */
  .masthead {
    padding-top: calc(clamp(0.9rem, 2.2vw, 1.7rem) + env(safe-area-inset-top));
    padding-left: calc(var(--gutter) + env(safe-area-inset-left));
    padding-right: calc(var(--gutter) + env(safe-area-inset-right));
  }
  .colophon {
    padding-bottom: calc(clamp(2rem, 5vh, 3.5rem) + env(safe-area-inset-bottom));
    padding-left: calc(var(--gutter) + env(safe-area-inset-left));
    padding-right: calc(var(--gutter) + env(safe-area-inset-right));
  }
}

/* The phone pass lives at the FOOT of this file, not here — several of its rules have to beat
   `#fire .display` and `#fire dl` in the plate block, which are (1,1,0) and cannot be outranked
   from up here without inventing specificity. Search for "the phone pass". */
@media (max-width: 620px) {
  :root { --stone-band: 34svh; }
}

/* Short landscape — a phone turned sideways. There is no room for a stone band above the copy,
   so the half-split comes back and the vertical padding goes instead. */
@media (orientation: landscape) and (max-height: 560px) {
  :root { --stone-band: 0svh; }
  .s {
    grid-template-columns:
      [full-start copy-l-start] var(--copy-col)
      [copy-l-end] minmax(1.5rem, 1fr)
      [copy-r-start] var(--copy-col) [copy-r-end full-end];
    grid-template-rows: auto;
    padding-block: 4.5rem 3rem;
    padding-inline: var(--edge-pad);
    align-content: center;
  }
  /* Undo the narrow block at matching specificity — a plain `.s > .copy` here would lose to the
     three [data-text-side] rules above and the plate would survive into landscape. */
  .s > .copy,
  .s[data-text-side="left"] > .copy,
  .s[data-text-side="right"] > .copy,
  .s[data-text-side="full"] > .copy {
    grid-row: auto;
    width: 100%;
    max-width: var(--measure);
    /* THIS BLOCK USED TO SET `padding: 0; background-color: transparent`, WHICH TOOK THE PLATE
       AWAY ENTIRELY. That is the one thing the foot of this file says must never happen: with a
       full-bleed additive particle field and a five-level bloom pyramid there is no dark half of
       the screen, and the layout alone measured 1.0:1 at 1440x900 before the plate existed.
       A phone in landscape is not a special case of that argument, it is the worst case of it —
       the copy columns are ~330px wide and the stone sits between them at full exposure, because
       @media (orientation: landscape) and (max-height: 560px) also sets --stone-band to 0 and
       choreography.js therefore leaves the portrait retreat behind.
       tools/test-contrast.mjs does not gate a landscape phone (VIEWPORTS is 1440x900, 1215x1304
       and 390x844), so this path was never measured, and an unmeasured path with the readability
       floor deliberately removed is a guess. The plate stays. It is thinner than the portrait
       one because there IS a stone-free half here for it to sit in.
       MEASURED, A/B, `node tools/capture.mjs --viewport 844x390 --dpr 2`, worst ratio per section:
                        entry  deposits  cut   catalogue  fire   parcels  contact
         transparent    3.84    1.48    2.35     4.79     7.55    1.08     1.00   <- what shipped
         plate 0.82     7.52    7.72    7.69     6.88     7.12    7.67     7.16
       #contact at 1.00:1 is text the same colour as what is behind it: the stone sits directly in
       the copy column with nothing in between. Four of the seven sections were under AA and three
       of those were under 2.5:1. This is why the plate is not optional on any path. */
    --plate-pad: clamp(0.9rem, 1.6vw, 1.6rem);
    padding: var(--plate-pad);
    margin: calc(-1 * var(--plate-pad));
    /* --plate-alpha is 1 on every viewport the narrow block matches, and a landscape phone is
       one of them (844x390 is under max-width 1180). The 0.82 stays as the fallback so the
       number the A/B above measured is still the number this rule degrades to. */
    background-color: rgba(4, 5, 7, var(--plate-alpha, 0.82));
    border-top: 0;   /* the plate is inset here, not a full-bleed band; a top hairline would float */
  }
  .s[data-text-side="left"]  > .copy { grid-column: copy-l-start / copy-l-end; justify-self: start; }
  .s[data-text-side="right"] > .copy { grid-column: copy-r-start / copy-r-end; justify-self: end; }
  .s[data-text-side="full"]  > .copy { grid-column: full-start / full-end; }
  /* Put the radial wash back. The narrow block above (which also matches a landscape phone, on
     width) turned this pseudo-element into a vertical fade for the full-bleed band; here the copy
     is a column beside the stone again, so it wants the outer-edge wash it has on a desktop. */
  .copy::before {
    display: block;
    inset: -2.5rem -3.5rem;
    background: radial-gradient(
      115% 105% at var(--scrim-origin, 0%) 50%,
      rgba(4, 5, 7, 0.34),
      rgba(4, 5, 7, 0.2) 52%,
      rgba(4, 5, 7, 0) 80%
    );
  }
  .display-lines, .figures { padding-inline: 0; background-color: transparent; }
  .figures { justify-content: center; padding-top: 1.1rem; }
  .s-fire { grid-template-rows: auto minmax(20svh, 1fr) auto auto; }
}

/* ── preferences ───────────────────────────────────────────────────────────────────────── */

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  #grain { animation: none; }
  .scroll-cue::after { animation: none; transform: none; opacity: 0.7; }

  /* The glyph reveal must degrade to plain text, not to invisible text: if the reveal never
     runs, the type still has to be on the page.
     src/textfx.js already refuses to split at all when this preference is set, and re-splits
     live if it is turned off — so under reduced motion there are normally no .g spans in the
     document to override. This stays as the belt to that braces: a page whose module was
     installed before the preference flipped, or whose module threw halfway, still shows every
     word, whole, opaque and where it belongs. clip-path is in the list because the facet
     shatter uses one, and a clip left behind is a letter with a corner missing. */
  [data-split] .g, .fx-split .g {
    transform: none !important;
    translate: none !important;
    opacity: 1 !important;
    clip-path: none !important;
    filter: none !important;
    will-change: auto !important;
  }
  /* The section-blend transforms are motion too. Pin them to their resting values rather than
     letting them ride --sec-in. */
  .eyebrow .rule { transform: none !important; }
  .copy::before { opacity: var(--scrim) !important; }
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
}

/* Someone who asked the OS for more contrast gets the ceiling, and the scrim stops being
   optional for them. */
@media (prefers-contrast: more) {
  :root {
    --body: var(--ink);
    --dim: #cdd3db;
    --quiet: #cdd3db;
    --edge: #4a525e;
    --edge-2: #6b737f;
    --plate: rgba(4, 5, 7, 0.97);
  }
  .copy::before {
    background: radial-gradient(
      120% 110% at var(--scrim-origin, 0%) 50%,
      rgba(4, 5, 7, 0.92),
      rgba(4, 5, 7, 0.78) 60%,
      rgba(4, 5, 7, 0) 92%
    );
  }
}

/* Windows high-contrast / forced colours: the canvas stops being a background anyone can rely
   on, so drop it and let the system palette own the page. */
@media (forced-colors: active) {
  #stage, #grain { display: none; }
  .copy::before, .masthead::after { display: none; }
  .s > .copy { grid-column: full-start / full-end !important; background: Canvas; --plate-pad: 0px; border-top: 0; }
  .species-btn[aria-pressed="true"] { forced-color-adjust: none; background: Highlight; color: HighlightText; }
}

/* ── degraded path ─────────────────────────────────────────────────────────────────────── */
/* main.js puts .no-webgl on <html> when the context fails. The page then has to stand up as a
   typographic document: no dead canvas, no half-empty columns waiting for a stone, no plates
   defending against a render that is not happening. */

.no-webgl #stage { display: none; }
.no-webgl body { background: radial-gradient(120% 90% at 50% 0%, #101520, #040507 70%) fixed; }
.no-webgl .copy::before, .no-webgl .masthead::after { display: none; }
.no-webgl .masthead, .no-webgl .colophon, .no-webgl .rail ol,
.no-webgl .scroll-cue, .no-webgl .display-lines, .no-webgl .figures { background-color: transparent; }
.no-webgl .s { align-content: center; grid-template-rows: auto; }
.no-webgl .s > .copy {
  grid-column: full-start / full-end !important;
  grid-row: auto;
  --plate-pad: 0px;   /* no render to defend against, and no negative margin to go with it */
  padding: 0;
  width: 100%;
  max-width: var(--measure);
  background-color: transparent;
  border-top: 0;
}
.no-webgl .copy::before { display: none; }
.no-webgl .s { padding-inline: var(--edge-pad); }
.no-webgl .display-lines, .no-webgl .figures { padding-inline: 0; }
.no-webgl .s-fire { grid-template-rows: auto 0 auto auto; }
.no-webgl .scroll-cue { display: none; }

/* ── measured fix ───────────────────────────────────────────────────────────────────────────
   The contrast sweep (31 scroll positions × every marked copy box, background read from the
   composited frame) leaves exactly one failure: the small label in #fire at 3.04:1 against
   #5e4f38, which is the stone's own glow. #fire is the one section that puts display type over
   a large centred stone, so the label has nowhere to move — it gets the bright ink instead of
   the dim, which measures ~6.9:1 against that same background.
   ───────────────────────────────────────────────────────────────────────────────────────── */
#fire .eyebrow,
#fire .eyebrow span,
#fire [data-field='eyebrow'] {
  color: var(--ink);
}
#fire .eyebrow .rule { background-color: var(--edge-2); }

/* ── the copy plate — measured, not decorative ──────────────────────────────────────────────
   The original scrim was a gradient on .copy::before, deliberately invisible to probe.js so the
   gate would measure the layout alone. Honest instinct, wrong conclusion: with a full-bleed
   ADDITIVE particle field and a five-level bloom pyramid there is no such thing as a dark half
   of the screen. Measured at 1440x900 with the copy correctly pinned to its own column, the
   background under body copy still reached #988477 and the page ran at 1.0:1.

   So the plate is now real, flat, on the element itself, and therefore composited exactly by the
   probe: the gate measures what ships instead of measuring a page that does not exist. The alpha
   below was chosen by sweeping it against the gate, not by eye.
   ───────────────────────────────────────────────────────────────────────────────────────── */
.copy {
  /* 0.88, not 0.78: with the honest gate running, the labradorite behind the catalogue spec
     table still composited through a 0.78 plate to #848b93 and put body copy at 2.03:1. */
  background-color: rgba(4, 5, 7, var(--plate-alpha, 0.88));
  padding: var(--plate-pad);
  /* The negative margin cancels the padding so the plate grows OUTWARD from the copy without
     moving the column inside the split grid. It is only correct where the plate is inset from the
     section's own padding, i.e. the wide two-column path.
     IT WAS NOT CANCELLED ANYWHERE ELSE, AND THAT WAS A REAL, VISIBLE DEFECT. Below --bp-split the
     narrow block gives the plate the full section width and its own padding, but this margin
     still applied: measured at 390x844 the plate sat at x = -17.6 .. 372.4, i.e. hanging 17.6px
     off the left edge of the screen and leaving a 17.6px unplated strip down the right, with body
     copy starting 2.4px from the left bezel and 37.6px from the right. Every section, every
     phone. Each override below now zeroes --plate-pad, which zeroes the margin with it, so the
     two can never drift apart again. */
  margin: calc(-1 * var(--plate-pad));
  /* the plate is the readability floor; the gradient pseudo-element remains as a soft edge */
}

#fire .display,
#fire [data-field='display'],
#fire .eyebrow,
#fire .meta,
#fire dl,
#fire h2 {
  background-color: rgba(4, 5, 7, var(--plate-alpha-fire, 0.72));
  padding: 0.35em 0.7em;
}

html[data-scrim='off'] .copy,
html[data-scrim='off'] #fire .meta,
html[data-scrim='off'] #fire .display,
html[data-scrim='off'] #fire h2 { background-color: transparent; }

/* ── measurement freeze ─────────────────────────────────────────────────────────────────────
   The contrast gate records each text run's box, then takes two screenshots. Anything still
   animating between those steps is sampled at the wrong place: the rail carries a 0.5s side
   transition and flips at the parcels boundary, so at one scroll stop in thirteen the gate
   recorded rail-n at left 42.58 and photographed it at left 1286 — it measured empty page twice
   and reported 1.00:1 for text that photographs at 6.66:1. The same staleness made the run
   non-deterministic (52 / 54 / 62 failures on identical code).

   probe.js sets data-measuring for the duration of a measurement. WCAG is about the settled
   state, so freezing transitions does not weaken the test — it is the only way to make it mean
   anything at all.
   ───────────────────────────────────────────────────────────────────────────────────────── */
html[data-measuring] *,
html[data-measuring] *::before,
html[data-measuring] *::after {
  transition: none !important;
  animation: none !important;
}
html[data-measuring] { scroll-behavior: auto !important; }

/* The per-glyph reveal wraps glyphs in transformed .g spans. The gate paints a solid slab over an
   element's BOX and photographs it, so a glyph displaced out of its parent's box makes the slab
   and the glyph land in different places — the gate then reports "the slab never reaches the
   declared colour" and calls readable text a 1.00:1 failure. Neutralising the transforms during
   measurement puts every glyph back where its box says it is, which is also exactly where the
   reveal converges. */
html[data-measuring] .g,
html[data-measuring] .w,
html[data-measuring] [data-split] * {
  transform: none !important;
  opacity: 1 !important;
  filter: none !important;
  clip-path: none !important;
}

/* ── the phone pass ─────────────────────────────────────────────────────────────────────────
   Everything above treats "narrow" as one case: `(max-width: 1180px), (orientation: portrait)`
   also matches a 768x1024 tablet and the gated 1215x1304 viewport, so type set there has to
   still be right at 1215px wide. This block is the case where it does not — a real phone.

   IT IS AT THE FOOT OF THE FILE ON PURPOSE. The plate block above declares `#fire .display`,
   `#fire .eyebrow` and `#fire dl` at specificity (1,1,0); a class selector inside the narrow
   media query is (0,1,0) and silently loses to them. That is not hypothetical — it is why the
   dispersion strip in #fire was being padded 0.35em/0.7em on a phone while the narrow block above
   said 1.25rem/--edge-pad and appeared to be in charge. Ordering is the honest fix; adding an
   id to a layout selector to win an argument is not.

   THE HEIGHT GUARD. `(min-height: 561px)` is the exact complement of the short-landscape block
   above, which starts at `max-height: 560px`. Without it a 568x320 phone on its side would take
   the portrait type scale on top of the landscape two-column layout.

   CONTRAST: every size below stays under WCAG's 24px large-text threshold, so every one of these
   runs is graded at 4.5:1 both before and after — no element changes which bucket it is scored
   in. The one that could have is h2, and its clamp floor (1.6rem = 25.6px) is set to keep it
   above 24px down to a 320px screen. Re-measured: `npm run test:contrast`, 11/11.
   ───────────────────────────────────────────────────────────────────────────────────────── */

@media (max-width: 620px) and (min-height: 561px) {
  /* ── THE FIXED STAGE BAND ───────────────────────────────────────────────────────────────────
     THE OWNER, ON THE PHONE, VERBATIM: «на телефоне сайт просто мертвый. Физически.»

     WHY IT WAS. The reserved --stone-band sat at the TOP OF EVERY SECTION, and a section here is
     1.4–2.6 viewports tall, so that band is off screen for most of the scroll. Measured at 390x844:
     the stone sat behind an opaque copy plate at 44 of 60 scroll stops, at the worst of them over
     100% of its own height. src/main.js answered by fading it out after the hero — an honest
     retreat from an unsolvable layout, and the reason the page reads as dead.

     WHAT REPLACES IT. The band stops moving with the document. The canvas is promoted ABOVE the
     copy and clipped to a fixed band at the top of the VIEWPORT; the copy scrolls UNDER the band
     instead of over the stone. The per-section reserved band is deleted — there is nothing left to
     reserve. The stone is then on screen at EVERY scroll position of EVERY section.

       z 40  .masthead   fixed, [0, --band-top], opaque --plate (#040507 in the narrow block)
       z 20  #grain      unchanged, still above everything
       z 11  #stage      fixed, clipped to [--band-top, --band-bottom]        <- was z 0
       z  5  main        in flow, .copy plates opaque #040507                 <- unchanged
             the gaps    the browser's own color-scheme: dark viewport fill   <- unchanged

     CONTRAST IS A STRUCTURAL CLAIM HERE, NOT A COLOUR CHOICE: no glyph is ever composited against
     the render. Every text node on a phone is in exactly one of three states and there is no
     fourth — inside the opaque .masthead; below --band-bottom, where the canvas is clipped away
     and paints zero pixels and the stack is bit-for-bit what it is today; or between the two
     edges, where it is covered by an opaque canvas (alpha: false) and therefore not being read.
     That last state is the one src/probe.js has to know about, and it reads the clip below off
     #stage itself rather than being told about it.

     NO OPAQUE FULL-VIEWPORT LAYER IS ADDED, at any z-index, and `html`/`body` keep no background —
     see the invariant at the top of this file. A shade layer between the masthead and the band
     would be one more thing for probe.js::overlays() to fail to model, and it is unnecessary: the
     masthead is opaque over [0, --band-top] and the clipped canvas is opaque over
     [--band-top, --band-bottom]. They abut, because --band-top IS the masthead's measured height.

     THE TWO CUSTOM PROPERTIES. --band-top is written by src/mobile.js from the masthead's measured
     box (cached on resize, never read in a frame) — measured and not hardcoded to 52 because
     viewport-fit=cover plus a notch changes it. --band-h is written once per frame from
     choreography.js::bandAt(u), so the clip and the stone's `dist` come out of one call and cannot
     disagree. The values below are the FALLBACKS the first paint and the no-JavaScript path use;
     3.25rem is the masthead's height at this width with no safe-area inset. */
  :root {
    /* The per-section reserved band is deleted. It stays at 40svh for 621–1180px, where the
       gated 1215x1304 viewport still depends on it. */
    --stone-band: 0svh;
    --band-top: calc(3.25rem + env(safe-area-inset-top));
    --band-h: 30svh;
    --band-bottom: calc(var(--band-top) + var(--band-h));

    /* ── THE BAND IS A SCROLLPORT INSET, AND SAYING SO FIXES TWO THINGS ──────────────────────
       1. ANCHORS. .mark-cta and the parcels call-to-action both target #contact, and `main` is
          in normal flow, so the browser lands the target's top edge at viewport y 0 — under the
          masthead and under the band, i.e. the reader taps "write to us" and arrives at the
          middle of a paragraph with the heading hidden behind the stone. scroll-padding-top is
          the standard way to tell a scroll container that its top N pixels are spoken for;
          `scroll-behavior: smooth` and find-in-page honour it too.
       2. IT IS THE ONE RESOLVED NUMBER THE PROBE CAN READ. --band-bottom is a chain of calc(),
          rem, svh and env(), and getPropertyValue on a custom property hands back the unresolved
          string ("calc(calc(3.25rem + 0px) + 30svh)") — a custom property has no computed type to
          resolve against. A real CSS property does: the computed value of scroll-padding-top is
          an absolute length, so src/probe.js reads this one declaration and gets the band's true
          bottom edge in CSS px, at whatever the safe-area inset and the current --band-h actually
          are. No parsing of clip-path, no second copy of the arithmetic, and no way for the two
          to disagree — they are literally the same declaration. */
    scroll-padding-top: var(--band-bottom);
  }

  #stage {
    z-index: 11;
    clip-path: inset(var(--band-top) 0 calc(100% - var(--band-bottom)) 0);
  }

  /* The document opens in the hero pose: the first line of copy starts exactly where the 0.52
     band ends, so nothing is under the stone on the first screen. 52svh is BAND_KEYS' first
     value; if that key changes this changes with it. */
  main { padding-top: calc(var(--band-top) + 52svh); }

  /* A 40px opaque-to-transparent wash that would otherwise paint over the crown of the stone.
     It exists to soften the masthead's bottom edge against scrolling copy; there is no scrolling
     copy there any more, and the edge it softens is now the top of the render. */
  .masthead::after { display: none; }

  /* THE SEAM at --band-bottom is a hard 1px hairline, not a fade. A dissolve would put faded type
     over the render at about 2:1 and either fail the gate or need an exemption; a hairline is a
     rule, and --edge is a rule colour. It is drawn from the masthead because the masthead is the
     one element that is already fixed, already above the canvas, and already the owner of
     --band-top — so the hairline cannot drift away from the edge it declares.

     MEASURED DEFECT, FIXED HERE. This was `position: absolute; top: calc(var(--band-bottom) -
     var(--band-top))`, reasoning that an absolute child of the masthead is offset from the
     masthead's own top. It is not: the containing block for an absolutely positioned child is the
     padding box, and .masthead carries `padding: clamp(0.9rem, 2.2vw, 1.7rem) var(--gutter)`. The
     hairline therefore landed --band-top ABOVE the edge it names — photographed at 390x844: the
     rule sat at y 440 while the render ran to y 491 in #fire, and at y 254 against a clip at 306
     in #parcels, where it put a hard rule across empty sky and left the line of copy passing under
     the real edge looking sliced in half by nothing.

     `position: fixed` is the fix and not a workaround: --band-top and --band-bottom are both
     VIEWPORT coordinates (mobile.js measures the masthead against the viewport and choreography.js
     solves the stone's `dist` from the same interval), so the one element that declares that
     interval visually has to be positioned in the same space. .masthead has no transform, filter or
     backdrop-filter, so it does not become a containing block for a fixed descendant, and no
     `overflow` that could clip one. z 40 keeps the rule above the canvas at z 11. */
  .masthead::before {
    content: "";
    position: fixed;
    left: 0;
    right: 0;
    top: var(--band-bottom);
    height: 1px;
    background: var(--edge);
    pointer-events: none;
  }

  :root {
    /* --fs-xs / --fs-sm / --fs-mono were FIXED rem values, so a phone got exactly the size a
       1440px desktop got: 10.9px for tracked uppercase Cyrillic in JetBrains Mono, which is the
       smallest, hardest-to-read thing on the page and it is the page's labelling system. Each
       goes up by about a millimetre and gives back some of the tracking, which is what was
       actually costing legibility at that size.
       These are steps at 620px, not clamps, and the step is ~1px — under the threshold at which a
       reader can see a jump while resizing, and the alternative (a negative-slope clamp) hides a
       deliberate decision inside arithmetic nobody will read. */
    --fs-xs: 0.74rem;      /* 11.8px, was 10.9 */
    --fs-sm: 0.80rem;      /* 12.8px, was 12.2 */
    --fs-mono: 0.86rem;    /* 13.8px, was 13.1 */
    --ls-mono: 0.13em;     /* was 0.18em — tracking that reads as style at 1440 reads as gaps here */

    /* The lede was 17.9px against body copy at 16.2px. A 1.7px difference is not a hierarchy, it
       is a wobble; the lede already carries --ink against --body, which is the signal that
       actually reads. Make the size difference deliberate instead of accidental. */
    --fs-lede: 1.08rem;    /* 17.3px */

    /* #fire's two mono blocks. 16.8px uppercase mono in a 350px column is ~24 characters a line;
       both of these are full sentences and both were breaking three times.
       --fs-lines GOES BACK UP, to 18.4px. It was cut to 15.0px when it was a caption over live
       smoke; with the band in place it is display type on an opaque strip and it can be read like
       one. 18.4px is still under WCAG's 24px large-text threshold, so it stays in the 4.5:1 bucket
       it is already graded in — no element on this page changes which bucket it is scored in. */
    --fs-lines: 1.15rem;   /* 18.4px, was 15.0 */
    --fs-figure: 0.88rem;  /* 14.1px */

    /* ── LEADING FOR A SHORT MEASURE ──────────────────────────────────────────────────────────
       1.62 is drawn for the desktop column. MEASURED on this page at 390x844: the body column is
       350px and Ukrainian in Manrope at 16.2px sets 29.7–39.2 characters a line, median 35.5
       (counted off Range.getClientRects() over every .ledes and .stone-copy paragraph — lines,
       not estimates). A 35-character line does not need 26px of leading; the eye is returning to
       the left edge twice as often as the leading is built for, and the extra 8% of white just
       makes the paragraph taller than the phone.
       16px stays. It is the floor at which body copy on a phone is comfortable, and going down
       to buy a longer line would trade a real problem for a worse one.
       Contrast note: probe.js samples per LINE BOX, so tightening the leading tightens the
       sampled band around the glyphs rather than loosening it — the measurement gets harder,
       not easier. Re-measured either way. */
    --lh-body: 1.52;
  }

  /* ── touch ─────────────────────────────────────────────────────────────────────────────────
     Two things a page authored on a desktop never has and a phone always shows.
     1. The default tap flash on Android Chrome is a translucent BLUE rectangle. On a page whose
        entire palette is one warm accent over near-black it is the loudest colour on screen and
        it is the browser's, not ours.
     2. Every state on this page is a :hover state, and a touch screen has no hover. Without an
        :active the species chips and the two call-to-action bars give no acknowledgement at all
        — the tap either works or appears to have missed.
     Both stay light-on-dark. The pressed species pill was an inversion once and it cost a whole
     debugging session in the contrast gate (see .species-btn above); nothing here swaps ends of
     the scale, and :active is a state no measurement is ever taken in. */
  a, button { -webkit-tap-highlight-color: rgba(255, 138, 61, 0.22); }
  @media (hover: none) {
    .species-btn:active { border-color: var(--accent); color: var(--ink); }
    .cta-wrap a:active, .cta:active, .mark-cta:active { color: var(--accent); border-color: var(--accent); }
  }

  /* .masthead::after — the 4.5rem wash under the bar — was retuned here to 2.5rem for the phone,
     back when copy scrolled directly under the header and the first line photographed greyer than
     the second. It is `display: none` above now: the thing immediately under the bar is the render,
     not copy, and a wash over it would paint on the crown of the stone. The retune is deleted
     rather than left sitting under the display:none, because a rule that cannot apply is a rule the
     next reader has to work out is dead. */

  /* ── headings ──────────────────────────────────────────────────────────────────────────────
     Unbounded is a wide face and the negative tracking on these was drawn for a system sans at
     display size. At 26px the same -0.035em is -0.91px, which on uppercase Cyrillic closes the
     counters and makes ЖИТОМИРЩИНА read as one shape. Ease it off as the size comes down. */
  .wordmark { letter-spacing: -0.03em; line-height: 0.94; margin-bottom: 1.1rem; }
  /* 1.08 is too tight for the 2–3 line uppercase Cyrillic titles this page actually has; at 26px
     the descender-free caps sit on top of each other and «ВОЛИНЬ, КЛЕСІВ, ЖИТОМИРЩИНА» reads as a
     block rather than as three lines. `overflow-wrap` is deliberately NOT relaxed to `normal` here
     — see the last-resort rule at the foot of this file, which a measured fix installed after 11
     heading boxes ran off the right edge, and which `.fx-split .w { white-space: nowrap }` already
     stops from breaking a word mid-glyph. */
  h2 { letter-spacing: -0.015em; line-height: 1.13; margin-bottom: 1rem; }
  .stone-name { line-height: 1.18; gap: 0.5rem; }

  .eyebrow { gap: 0.55rem; margin-bottom: 1.1rem; line-height: 1.5; }
  .rule { width: 1.25rem; }
  .tagline { margin-bottom: 1.3rem; }

  .ledes { gap: 0.85rem; }
  .ledes > p:first-child { line-height: 1.5; }
  .ledes:not(:empty) { margin-bottom: 1.5rem; }
  .stone-copy { gap: 0.8rem; }

  .mark-uk { display: none; }

  /* ── data tables ───────────────────────────────────────────────────────────────────────────
     Key on its own line, value under it. A right-aligned value column in a 350px box shreds
     multi-word Ukrainian values into one word per line. */
  .spec > div, .meta > div, .readout > div { flex-wrap: wrap; gap: 0.2rem 1rem; padding: 0.6rem 0; }
  .spec dd, .meta dd, .readout dd { text-align: left; flex-basis: 100%; }
  .colophon { flex-direction: column; align-items: flex-start; gap: 0.5rem; }

  /* ── tap targets ───────────────────────────────────────────────────────────────────────────
     44px is the floor Apple's HIG and Android's Material both publish, and nothing in the
     masthead met it: .mark-cta measured 20px tall, .mark 20px, a species chip 36px.

     The two masthead links get an INVISIBLE hit area rather than real padding, and that choice is
     load-bearing. Real padding grows the fixed masthead, a taller masthead covers more of the
     copy scrolling under it, and probe.js grades that copy where it lies — so a tap-target fix
     done with padding is a contrast change wearing a usability badge. An absolutely positioned
     ::after on the link itself is inside the link, so it takes the taps, and it changes no box on
     the page. Nothing else on the page uses ::after on these two elements. */
  .mark, .mark-cta { position: relative; }
  .mark::after, .mark-cta::after {
    content: "";
    position: absolute;
    inset: -0.85rem -0.5rem;
  }

  /* The chips are inside the plate, so growing them is free: the background they sit on is the
     same var(--plate) before and after. */
  .species-btn {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
    padding: 0 0.9rem;
    scroll-snap-align: start;
  }
  /* ONE ROW, NOT TWO RAGGED ONES. Measured at 390x844: the five pills come to 477px of content in
     a 350px channel, so they wrapped to two rows of which the second held one pill — the catalogue
     control read as a mistake before it read as a control. A horizontal scroller keeps the row a
     row and keeps every pill at its 44px tap height.
     `overscroll-behavior-x: contain` is load-bearing on a touch screen: without it a horizontal
     fling that reaches the end of the strip is handed to the browser as a back-navigation gesture.
     NOT `position: sticky`. A pinned strip would float over body copy, and an opaque strip over
     measured text is an OCCLUDED failure in the contrast gate, correctly reported — the picker
     stays in the flow, inside the plate, where every pill is already 12.2:1. */
  .species {
    gap: 0.45rem;
    margin-bottom: 1.4rem;
    flex-wrap: nowrap;
    align-self: stretch;
    overflow-x: auto;
    overflow-y: hidden;
    scroll-snap-type: x proximity;
    overscroll-behavior-x: contain;
    scrollbar-width: none;
    padding-bottom: 2px;   /* room for the focus ring's outline-offset, which would otherwise clip */
  }
  .species::-webkit-scrollbar { display: none; }
  .species > li { flex: 0 0 auto; }

  /* A phone CTA is a full-width bar. .copy is a flex column with align-items: flex-start, so the
     wrapper has to be told to stretch before the anchor inside it can fill anything. */
  .cta-wrap { align-self: stretch; margin-top: 0.6rem; }
  .cta-wrap a, .cta {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    min-height: 48px;
    padding: 0.85rem 1.2rem;
    text-align: center;
  }

  /* ── #fire ─────────────────────────────────────────────────────────────────────────────────
     The rules that need the foot of the file. #fire's plates were small boxes hugging their text
     — a floating grey rectangle around the word ВОГОНЬ, another around the eyebrow, both with
     hard edges, neither aligned to anything, and both landing on top of the masthead on the way
     past. On one column they become full-width bands, the same shape and the same inset as
     .display-lines and .figures directly below them, so #fire reads as four stacked bands with
     the stone in the gap. Strictly MORE plate area than before, so this cannot cost a point of
     contrast; re-measured anyway. */
  /* THE VOID ROW GOES. It existed to keep the stone's centre — where it is brightest — off a line
     of type, on a layout where the stone was drawn behind the type. The stone is not behind the
     type any more; it is in the band, and #fire's three bands are three plates with nothing
     between them. Keeping an empty row here would only push the figures strip off the screen. */
  .fire-void { display: none; }
  .s-fire {
    grid-template-rows: auto auto auto;
    align-content: center;
    padding-top: var(--edge-pad);
  }

  #fire .fire-top { width: 100%; }
  #fire .eyebrow,
  #fire .display,
  #fire [data-field='display'],
  #fire h2 {
    width: 100%;
    justify-content: center;
    padding: 0.7rem var(--edge-pad);
  }
  #fire .display-lines,
  #fire .figures {
    width: 100%;
    padding: 1.1rem var(--edge-pad);
    background-color: var(--plate);
  }
  #fire .display-lines { gap: 0.7rem; line-height: 1.35; }
  #fire .figures { border-top: 0; }
  #fire .figures > div { padding: 0.5rem 0; }

  /* ONE PLATE PER BAND, NOT ONE PER ELEMENT. .fire-top is a single grid row holding the eyebrow
     and the title; plating each of them separately drew two rectangles with a stripe of raw
     render between them, at the top of a section whose whole idea is the stone in the gap.
     Move the plate up to the row. Same ink, one edge, and strictly more covered area than two
     boxes hugging their own text — so this cannot cost the gate a point either. */
  #fire .fire-top {
    background-color: var(--plate);
    padding-block: 0.9rem 1.1rem;
  }
  #fire .eyebrow,
  #fire .display,
  #fire [data-field='display'],
  #fire h2 { background-color: transparent; padding-block: 0.2rem; }

  /* The last row of .figures is not a figure, it is the units note — a full sentence in the
     value slot. Keyed left and right-aligned it wrapped to two ragged right-aligned lines and
     was the only block in #fire that looked like a mistake. Stack it: key, then the sentence
     under it, left, the same shape .meta rows take on this viewport.
     :last-child rather than an id or a count — content-uk.js puts the note last in
     SECTIONS.fire.meta, and if a sixth species is ever added it still lands above the note. */
  #fire .figures > div:last-child {
    flex-wrap: wrap;
    row-gap: 0.2rem;
    margin-top: 0.35rem;
    padding-top: 0.7rem;
    border-top: 1px solid var(--edge);
  }
  #fire .figures > div:last-child dd { flex-basis: 100%; text-align: left; }
}

/* ── flex items must be allowed to shrink ───────────────────────────────────────────────────
   Measured: #deposits h2 «Волинь, Клесів, Житомирщина» rendered 727 px wide inside a 556 px
   column and ran 179 px past the right edge of a 1440 px window. The markup was innocent — the
   words carry real spaces between them and the heading's white-space is normal.

   .copy is a flex container, and a flex item's default `min-width: auto` is its MIN-CONTENT
   size, so an item whose max-content width exceeds the container simply overflows instead of
   wrapping. Unbounded is a wide face and the headings got wider when the type changed, which is
   why this appeared with the new fonts and not before.

   min-width: 0 restores the ability to shrink; max-width: 100% caps it whatever the flex
   direction turns out to be.
   ───────────────────────────────────────────────────────────────────────────────────────── */
.copy > *,
.stone-meta > *,
.copy h1,
.copy h2,
.copy h3,
.copy p,
.copy dl {
  min-width: 0;
  max-width: 100%;
}

/* Last-resort break. The scale above is set so it should never fire, but a copy edit is one word
   away from re-creating the overflow and a heading hanging off the page is worse than a hyphen. */
.copy h1, .copy h2, .copy h3, .fx-split { overflow-wrap: break-word; }
/* NOT `normal`. The glyph spans inside a word are atomic inlines, so allowing the word to wrap
   lets it break between any two LETTERS — «П'ять видів» rendered as «П'ять в / идів». The
   type scale above is what keeps long words inside the column; this keeps them whole. */
/* Same correction as the copy of this rule higher up: `normal` breaks words between their own
   glyph spans and no overflow-wrap value prevents it. Measured 2 of 3 words broken at 390 px. */
.fx-split .w { white-space: nowrap; }

/* THE ONE HEADING WITH A WORD WIDER THAN ITS OWN COLUMN IS SIZED AGAINST THAT WORD.
   «Житомирщина» is 11 characters of Unbounded and it cannot break, because .w above is nowrap and
   for good reason. Measured at 320x700 it is 303 px of word in a 243 px column, right edge 342 in
   a 320 px viewport — and `body` is `overflow-x: clip`, so the tail is not scrolled to, it is CUT
   OFF. At 1440 the same word is ~748 px against a ~510 px column.
   Two alternatives were built and measured and are worse:
     · `white-space: normal` on the whole heading — «Клесів,» broke across two lines as well. The
       breaker uses the inter-glyph opportunities greedily to fill a line, not as a last resort.
     · `white-space: normal` on the last word only — it fits, and renders «ЖИТОМ / ИРЩИНА» in
       display type at every width including the desktop. A broken word in a display heading is
       what the reader notices first.
   So the heading takes the size the word allows. In Unbounded at this tracking the word measures
   11.14 em wide (303 px at 27.2 px, measured), so it fits any column at 8.8 % of that column's
   width — hence 8.8cqw, with `min()` so the authored scale still wins wherever it is smaller.
   `container-type: inline-size` on .copy is what makes cqw mean "this column". Verified inert:
   every .copy, every child of it, the rail, the masthead, the case and the colophon measured to
   the same box before and after, at 1440x900. This is the ONLY heading with the problem, so it is
   the only one capped — nothing else on the page changes size. */
.copy { container-type: inline-size; }
#deposits [data-split] { font-size: min(var(--fs-h2), 8.8cqw); }

/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   THE PHONE — a deck, not the desktop page reflowed.
   Ihor, twice, in his own words: «на весь экран вот камень, там текста даже нет… он на фоне, а
   текст уже поверх появляется, когда ты скроллишь. И он не скроллится вниз как бы, он просто
   меняется при скролле.»

   So `main` is pinned to the viewport and every section becomes a slide stacked in the same place.
   src/deck.js selects which slide is showing from scrollY and writes --slide-in on it. The scroll
   is still a real scroll — #deck-spacer gives the document its length — so momentum, the scrollbar,
   the URL bar, find-in-page and anchors all behave. Only the paint changes.
   ═══════════════════════════════════════════════════════════════════════════════════════════════ */

html[data-deck] { scroll-behavior: auto; }

html[data-deck] body { overflow-x: clip; }

html[data-deck] main {
  position: fixed;
  inset: 0;
  z-index: 5;
  /* The stone is behind and must stay reachable: only a slide that is actually showing takes
     pointer events, and it re-enables them on itself below. */
  pointer-events: none;
  display: block;
}

html[data-deck] #deck-spacer { width: 1px; }

html[data-deck] .s {
  position: absolute;
  inset: 0;
  display: grid;
  align-content: end;
  padding: calc(var(--masthead-h, 52px) + 1rem) var(--gutter) calc(2.5rem + env(safe-area-inset-bottom));
  min-height: 0;
  opacity: var(--slide-in, 0);
  /* The copy rises as it arrives. Small — 2.5% of the viewport — because the slide is already
     fading and two large motions on the same element read as a bounce. */
  transform: translateY(calc((1 - var(--slide-in, 0)) * 2.5svh));
  transition: none;
}

html[data-deck] .s[data-slide-state='far'] { visibility: hidden; }
html[data-deck] .s[data-slide-state='active'] { pointer-events: auto; }

/* THE FIRST SLIDE HAS NO TEXT. Its whole job is the object. The heading stays in the DOM for a
   crawler and a screen reader and is simply not painted. */
html[data-deck] #entry .copy > *:not(.wordmark),
html[data-deck] #entry .scroll-cue { display: none; }
/* THE WORDMARK IS THE BACKGROUND, on every slide, not a headline on the first one. It sits behind
   the stone at the stone's own height, sized to the viewport rather than to a type scale — the
   desktop has a WebGL echo of the section title doing this job and a phone cannot afford that
   pass, so the mark does it instead. */
html[data-deck] #entry .wordmark {
  position: fixed;
  inset: 0;
  display: grid;
  place-content: center;
  z-index: -1;
  text-align: center;
  font-size: min(26vw, 18svh);
  line-height: 1;
  letter-spacing: -0.02em;
  color: rgba(236, 238, 242, 0.085);
  pointer-events: none;
  /* Fades back as the reader moves into the document, so it is a ground on slide 00 and a
     watermark after it rather than competing with each slide's own words. */
  opacity: calc(1 - min(1, var(--deck-active, 0) * 0.34));
}

/* NO PLATE. Ihor: «не должно быть вообще никакого фона, там текст просто сверху появляется».
   He is right about what it looked like — a dark rectangle sliding up from the bottom of every
   slide, which is a scrim pretending to be a layout.

   Readability without a box: the stone is CENTRED on a phone, so the top and bottom bands of the
   frame are the darkest part of the render on every slide. Copy lives in those bands, and carries
   a text-shadow rather than a plate — a shadow tracks the glyph instead of drawing a shape, so it
   costs nothing visually and still lifts type off a bright pixel if the stone drifts under it.

   Honest note on the instrument: src/probe.js composites background-COLOR only, so it cannot see a
   text-shadow. The phone contrast number is therefore measured WITHOUT this help — the placement
   has to earn the ratio on its own, and the shadow is margin. That is the conservative direction,
   which is the one to be wrong in. */
html[data-deck] .copy {
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
  max-width: none;
  width: 100%;
  text-shadow: 0 1px 14px rgba(4, 5, 7, 0.95), 0 0 42px rgba(4, 5, 7, 0.8);
}

/* THE COPY IS NOT ALWAYS IN THE SAME PLACE. One box at the bottom of seven slides in a row is the
   same slide seven times. Each one is anchored where its own content wants to be, and the stone's
   per-slide height in deck.js is chosen against these. */
html[data-deck] .s { align-content: end; }
html[data-deck] #deposits  { align-content: start; }   /* the seams run low, the words sit high */
html[data-deck] #cut       { align-content: start; }
html[data-deck] #fire      { align-content: center; text-align: center; }
html[data-deck] #catalogue { align-content: end; }
html[data-deck] #parcels   { align-content: start; }
html[data-deck] #contact   { align-content: center; }

html[data-deck] #fire .copy { text-align: center; }
html[data-deck] #deposits .copy,
html[data-deck] #parcels .copy { padding-top: 0.5rem; }

/* Data tables are the one thing that genuinely needs a ground to sit on — a two-column row of
   small mono over a moving field is unreadable without one. A hairline rule and a very low
   plate, not a slab. */
/* NO GROUND ON A PHONE. «На телефоне не будет фонов ебучих вот этих вот с заливкой. И просто
   будет поверх текст сверху, внизу, там, в зависимости от элемента.»
   The slab was 62 per cent black across the full width, and on a screen where the render IS the
   page it reads as a card pasted over the picture — it was also the grey edge that was the only
   visible thing on the cut slide. Type over the render, carried by the same shadow the copy uses.

   HONEST NOTE ON THE INSTRUMENT: src/probe.js composites background-COLOR only, so the contrast
   gate cannot see a text-shadow and will score this lower than it looks. That is the conservative
   direction and the shadow is margin on top of the placement, not a substitute for it. */
html[data-deck] .meta,
html[data-deck] .readout,
html[data-deck] .spec {
  background-color: transparent;
  border-top: 0;
  padding: 0;
  text-shadow: 0 1px 14px rgba(4, 5, 7, 0.98), 0 0 30px rgba(4, 5, 7, 0.92), 0 0 60px rgba(4, 5, 7, 0.7);
}

/* Chrome a phone does not need: the section rail is a desktop affordance and it costs a third of
   the width. The masthead stays — it is the way back to the top. */
html[data-deck] .rail { display: none; }
html[data-deck] .colophon { display: none; }

/* Tap targets. 44px is the floor, and the catalogue pills were 30px. */
html[data-deck] .species-btn {
  min-height: 44px;
  padding: 0.6rem 0.9rem;
  font-size: var(--fs-sm);
}

html[data-deck] .cta { min-height: 48px; display: inline-flex; align-items: center; }

/* Type: the desktop scale is authored for a 1440 px column and Unbounded is wide. */
html[data-deck] {
  --fs-h1: clamp(2.1rem, 11vw, 3.1rem);
  /* 7.8vw, not 8.6: the longest single word on the page is «Житомирщина», and `.fx-split .w` is
     nowrap by construction (a word must never break mid-glyph), so a word wider than the column
     runs off the edge instead of wrapping. Measured at 8.6vw: 374 px of word in 353 px of column
     on a 390 px screen. 7.8vw still ran 3 px over once the copy's own padding was counted — the
     column's CONTENT box is narrower than its border box, which is the part the first correction
     missed. 7.4vw puts the word at 322 px inside a 336 px content box. */
  --fs-h2: clamp(1.7rem, 7.4vw, 2.4rem);
  --fs-display: clamp(2.2rem, 12vw, 3.4rem);
  --fs-body: clamp(0.98rem, 4.1vw, 1.08rem);
  --fs-lede: clamp(1.02rem, 4.4vw, 1.18rem);
  --gutter: 1.15rem;
}

@media (prefers-reduced-motion: reduce) {
  html[data-deck] .s { transform: none; }
}

/* ── the five cards, slide 05 ────────────────────────────────────────────────────────────────
   Ihor asked for five stones with their weights and prices in «рамкой контурной, очень
   малозаметной, белой». A hairline at 8% white is exactly that: it draws the boundary of the
   information without drawing a box around it, which is what a heavier border does — a box says
   "chrome", a hairline says "this belongs together".
   ───────────────────────────────────────────────────────────────────────────────────────────── */
.stone-cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
  gap: 1px;                     /* the gap IS the rule between cards */
  background-color: rgba(236, 238, 242, 0.08);
  border: 1px solid rgba(236, 238, 242, 0.08);
  margin-top: 1.4rem;
  list-style: none;
  padding: 0;
}

.stone-card {
  background-color: rgba(4, 5, 7, 0.86);
  padding: 0.85rem 0.9rem 0.95rem;
  display: grid;
  gap: 0.3rem;
  align-content: start;
  transition: background-color 0.25s ease;
}
.stone-card[data-current='true'] { background-color: rgba(14, 16, 21, 0.9); }

.stone-card .card-name {
  font-family: var(--display);
  font-size: var(--fs-mono);
  letter-spacing: 0.01em;
  color: var(--ink);
}
.stone-card .card-cut {
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: 0.06em;
  color: var(--dim);
}
.stone-card .card-size,
.stone-card .card-price {
  font-family: var(--mono);
  font-size: var(--fs-xs);
  letter-spacing: 0.04em;
  display: flex;
  justify-content: space-between;
  gap: 0.5rem;
  color: var(--body);
}
.stone-card .card-price { color: var(--accent); }
.stone-card .k { color: var(--quiet); }

html[data-deck] .stone-cards { grid-template-columns: repeat(2, 1fr); margin-top: 0.9rem; }
html[data-deck] .stone-card { padding: 0.6rem 0.65rem 0.7rem; }

/* ── the shelf: stones INSIDE their cells ────────────────────────────────────────────────────
   Ihor: «они должны быть вписанные в такую табличку в правой части экрана». The grid was in the
   copy column and the stones were floating beside it, which is a caption next to an object rather
   than an object in a case.

   The grid now sits in the half the copy leaves free, and each cell reserves a window at its top
   that main.js renders that species' stone into — the cell's own box is read once per resize and
   projected into world space, so the stone is positioned BY the layout instead of being lined up
   against it by hand. */
.s-parcels .stone-cards,
#parcels .stone-cards {
  grid-column: copy-r-start / copy-r-end;
  grid-row: 1;
  align-self: center;
  grid-template-columns: 1fr;
  gap: 1px;
  margin: 0;
}

#parcels .stone-card {
  grid-template-columns: var(--card-window, clamp(5rem, 9vw, 8rem)) 1fr auto;
  grid-template-areas: 'window name price' 'window cut size';
  align-items: center;
  gap: 0.15rem 0.9rem;
  padding: 0.55rem 0.9rem 0.6rem 0.6rem;
  min-height: 5.4rem;
}
#parcels .stone-card .card-window { grid-area: window; }
#parcels .stone-card .card-name  { grid-area: name; align-self: end; }
#parcels .stone-card .card-cut   { grid-area: cut; align-self: start; }
#parcels .stone-card .card-price { grid-area: price; align-self: end; justify-content: flex-end; }
#parcels .stone-card .card-size  { grid-area: size; align-self: start; justify-content: flex-end; }
/* A PRICE AND A WEIGHT ARE ONE LINE OR THEY ARE NOT A COLUMN.
   The name column is 1fr and the price column is auto, so a longer name takes width from the
   price. Measured at 390x844: «Лабрадорит» is 110 px against 99 px for every other name, and that
   11 px pushed «За запитом» onto a second line — one row in the case 36 px tall against 18 px, its
   price sitting above its own name.
   `min-width: 0` IS HALF THE FIX AND NOT AN AFTERTHOUGHT. A grid item's automatic minimum size is
   its min-content width, so without it the name column simply refuses to go below the widest word
   and the whole CARD grows past the case instead. Measured at 320x700, the row's right edge:
     nowrap alone                 366 — 64 px past the case, 32 px past the screen
     neither (what was shipping)  343 — 23 px past the case, 23 px past the screen, price on two
                                        lines. The overflow at 320 predates this rule.
     nowrap + min-width: 0        301 — inside the case, price on one line
   The name is the one thing allowed to give: `break-word` lets «Лабрадорит» split at 320 only,
   where nothing else fits, and leaves it whole from 360 up. */
#parcels .card-price > span:last-child,
#parcels .card-size > span:last-child { white-space: nowrap; }
#parcels .stone-card > * { min-width: 0; }
#parcels .card-name { overflow-wrap: break-word; }

/* The window is empty on purpose: the stone is drawn behind the page, in the canvas, at exactly
   this box.
   NO BORDER. «Не обязательно квадраты, просто сами рендеры нужны 3D-шные слева. Вместо квадратов.»
   The hairline was there to say "this is a setting, not a gap", and a stone big enough to read
   says that by itself. It also made the failure worse: a 105x70 box with a 20 px speck in it reads
   as an empty square with a fleck, which is what «не показывают эти топазы» was describing —
   measured, the render was there the whole time at 97-99 per cent coverage of the box.
   The box scales with the viewport now instead of being one fixed rem value at every width. */
.card-window {
  height: clamp(4.6rem, 8.5vw, 7.4rem);
  border: 0;
}

/* THE PHONE PUTS THE CASE UNDER THE COPY, IN NORMAL FLOW.
   The desktop rule above pins the cards to `grid-column: copy-r-start / copy-r-end; grid-row: 1`,
   which are named lines from the split layout. On the deck that layout is gone, so the named lines
   resolve against nothing: the section's computed columns came back `0px 0px 353px`, the cards
   took row 1 at top -255 (off the top of the screen) and `.copy` was pushed into row 2 at a width
   of FORTY PIXELS — which is why every word of the heading was measured overflowing its container
   on this slide, and why the composite showed the price rows printed across the masthead. */
/* A COLUMN, not a grid. `grid-row: auto` was not enough: `.copy` is explicitly placed in row 2,
   so the cards auto-placed into row 1 and came out ABOVE the copy (measured: copy 68-545, cards
   at top 86, printed straight through it). A grid with one explicit placement left in it is a grid
   that will keep doing that, so the deck drops the section's grid entirely and stacks the two
   children in document order. */
html[data-deck] #parcels {
  display: flex;
  flex-direction: column;
}
html[data-deck] #parcels > .copy,
html[data-deck] #parcels > .stone-cards {
  grid-column: auto;
  grid-row: auto;
  align-self: stretch;
  width: 100%;
}
html[data-deck] #parcels .stone-cards {
  display: grid;
  grid-template-columns: 1fr;
  margin-top: 1.1rem;
}
html[data-deck] #parcels .stone-card { grid-template-columns: 3.4rem 1fr auto; min-height: 3.2rem; }
/* The phone gets the same treatment at its own scale: 2.5rem was 40 px, which is a thumbnail of a
   thumbnail. 20vw is 78 px on a 390 screen and it still leaves the name, cut, weight and price
   their column. */
html[data-deck] .card-window { height: clamp(3.6rem, 20vw, 5.4rem); }
html[data-deck] #parcels .stone-card { grid-template-columns: clamp(4rem, 22vw, 6rem) 1fr auto; }

/* The shelf holds still in the viewport while the section scrolls past it. Measured: pinned to
   the section instead, the first cell sat at y −129 on a 900 px screen — the case was half off
   the top before the reader reached the copy that explains it. */
#parcels .stone-cards {
  position: sticky;
  top: 50svh;
  transform: translateY(-50%);
}

/* THE RAIL AND THE CASE WANT THE SAME STRIP, AND THE RAIL WAS WINNING.
   The rail moves to whichever half the copy is NOT in (see the .rail block) — which fixed it
   occluding two paragraphs of #parcels and handed the collision to the one section whose free
   half is not free: the case is there. Measured at 1440x900: rail x 1270-1417, case x 835-1391,
   121 px of overlap, rail opacity 1, its opaque plate sitting on the «Моріон» row's price and
   weight. So the case stops before the rail starts rather than the rail being hidden — a
   skip-list that disappears on one section is a worse answer than a slightly narrower case.
   9.5rem, measured: case right edge 1239 against a rail starting at 1270, 31 px clear. The window
   column is a clamp and does not move (128 px before and after), so the stones keep their size
   and the name/price columns take the difference. Only where the rail is actually shown — it is
   faded out below 1100 px, where the case has the width to itself. */
@media (min-width: 1100px) {
  html[data-rail-side='right'] #parcels .stone-cards { margin-right: 9.5rem; }
}

/* THE WINDOW IS A HOLE, not a box. Measured: the stones were rendering correctly inside every
   cell — peak luminance 198–255 in all five — and the card's own 86% black plate was sitting on
   top of them. A setting you cannot see through is not a setting.

   The plate is a gradient that starts where the window ends, so the canvas shows through the
   window column and the text still has its ground. One declaration, no extra element. */
#parcels .stone-card {
  background: linear-gradient(
    90deg,
    transparent 0 calc(var(--card-window, 5.5rem) + 0.6rem),
    rgba(4, 5, 7, 0.86) calc(var(--card-window, 5.5rem) + 0.6rem) 100%
  );
}
#parcels .stone-card[data-current='true'] {
  background: linear-gradient(
    90deg,
    transparent 0 calc(var(--card-window, 5.5rem) + 0.6rem),
    rgba(16, 19, 25, 0.9) calc(var(--card-window, 5.5rem) + 0.6rem) 100%
  );
}
/* The grid's own backdrop must not fill the window column either. */
#parcels .stone-cards { background-color: transparent; }
#parcels .stone-card + .stone-card { border-top: 1px solid rgba(236, 238, 242, 0.08); }

/* Phone: nothing may leave the frame. Measured at 390x844 — copy was running past the right edge
   because the deck's slides inherit a desktop measure and a phone has no second column to take
   the overflow. */
html[data-deck] .s > .copy,
html[data-deck] .copy > *,
html[data-deck] .ledes p,
html[data-deck] .meta,
html[data-deck] .readout {
  max-width: 100%;
  min-width: 0;
  overflow-wrap: break-word;
}
html[data-deck] .s { padding-left: var(--gutter); padding-right: var(--gutter); overflow: hidden; }
/* A slide that is not the one being read must not be reachable or readable — visibility alone
   leaves it in the accessibility tree and find-in-page still hits it. */
html[data-deck] .s[data-slide-state='far'] { content-visibility: hidden; }

/* ── what fits on one phone screen ───────────────────────────────────────────────────────────
   A deck slide cannot scroll — that is the whole point of it — so anything past the fold is
   simply not there. Measured on #catalogue at 390x844: the section's own heading and its five
   species buttons were pushed off the TOP by four paragraphs of prose below them, so the slide
   showed a sentence fragment and a spec table and nothing that said what you were looking at.

   Every slide keeps its eyebrow, its heading, its FIRST paragraph and its data table. The rest of
   the prose stays in the DOM — a crawler and a screen reader still get all of it, and the desktop
   shows all of it — and is not painted. Cutting what is shown is honest; letting it overflow off
   the top is not.
   ───────────────────────────────────────────────────────────────────────────────────────────── */
html[data-deck] .s .ledes p:nth-of-type(n + 2) {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
html[data-deck] #fire .display p { position: static; width: auto; height: auto; clip-path: none; white-space: normal; }

/* The catalogue's heading and its species buttons are the two things that make the slide legible,
   so it anchors to the top and its table takes whatever is left. */
html[data-deck] #catalogue { align-content: start; }
html[data-deck] #catalogue .spec { margin-top: auto; }


/* The species row is a control, not a headline: on a phone it wraps rather than running off the
   edge. Measured at 390 px — «Бурштин» was clipped and «Лабрадорит» was not on screen at all, so
   two of the five stones could not be selected. */
/* The list is `ul.species`, not `.species-row` — I styled a class that does not exist and the row
   kept overflowing while the rule sat there looking correct. */
html[data-deck] ul.species {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
  width: 100%;
  padding: 0;
  margin: 0.7rem 0 0.4rem;
}
html[data-deck] ul.species > li { flex: 1 1 auto; min-width: 5.2rem; display: flex; }
html[data-deck] .species-btn {
  flex: 1 1 auto;
  min-height: 40px;
  text-align: center;
  justify-content: center;
  padding: 0.5rem 0.4rem;
  font-size: var(--fs-xs);
  letter-spacing: 0.04em;
}

/* The card's text column sits over a backlit cell, so its plate has to be opaque enough to be the
   background rather than a tint on one. Measured: «грам» at 2.85:1 with the plate at 0.86 —
   OCCLUDED by the glow bleeding through 14% of it. */
#parcels .stone-card {
  background: linear-gradient(
    90deg,
    transparent 0 calc(var(--card-window, 6.6rem) + 0.5rem),
    rgba(4, 5, 7, 0.97) calc(var(--card-window, 6.6rem) + 0.9rem) 100%
  );
}

/* ── phone body copy is brighter, because it has no plate ────────────────────────────────────
   Measured at 390x844: four paragraphs at 4.22:1 against 4.5, all on the deposits slide during
   the cross-fade, where the copy is at partial opacity AND over the map. --body has headroom to
   L≈0.090 and the map pushes past it; --ink has headroom to L≈0.151.

   This is the cost of «не должно быть вообще никакого фона» and it is the right cost to pay: a
   brighter type colour is a design decision, a dark rectangle under every paragraph is a
   confession that the layout does not work. */
html[data-deck] .ledes p,
html[data-deck] .lede,
html[data-deck] .s p { color: var(--ink); }
html[data-deck] .meta dt,
html[data-deck] .readout dt,
html[data-deck] .spec dt { color: var(--dim); }

/* ── the deck overrides the old portrait band ────────────────────────────────────────────────
   The previous phone design clipped #stage to a band and pushed the copy below it. The deck
   replaced that model — the stone owns the whole screen and the copy comes over it — but the clip
   survived, so the canvas was still being cut to a 219–253 px strip on a 844 px screen. The gate
   caught it as "the band is empty at 25%", which was true and was the wrong question.

   These undo the band wholesale rather than editing it, because a half-removed layout is how the
   band survived the rewrite in the first place. */
html[data-deck] #stage {
  clip-path: none;
  z-index: 0;
}
html[data-deck] main { padding-top: 0; }
html[data-deck] { scroll-padding-top: 0; }

/* «ціна» and «грам» sit over a backlit cell through 3% of a 0.97 plate. --quiet has headroom to
   L≈0.020 and the composite lands at ≈0.026, which is why the gate kept catching them at 1.20:1
   and 2.85:1. --dim doubles the headroom to L≈0.040 and the label is still visibly secondary. */
#parcels .stone-card .k { color: var(--dim); }

/* The ground these tables used to have is gone — see THE PHONE HAS NO FILLS at the end of this
   file. It was rgba(4, 5, 7, 0.94), it was added because «Волинське пегматитове поле» composited
   to 1.01:1 over the map, and it is now a text-shadow instead. Keeping the padding: the type still
   needs its own space even without a box drawn round it. */
html[data-deck] .meta,
html[data-deck] .readout,
html[data-deck] .spec {
  padding: 0.7rem 0.8rem;
}

/* The cards leave with the shelf — by MOVING, not by fading.
   Fading them meant that at every scroll position inside the handover their labels were partly
   transparent over a live render, and the gate read that as what it is: text that never reaches
   its own contrast ratio. Opacity stays at 1 the whole way and the case is carried off the right
   edge instead, which is also the better picture — the shelf is taken away, it does not evaporate. */
#parcels .stone-cards {
  transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1), visibility 0s linear 0.45s;
  will-change: transform;
}
html[data-shelf='gone'] #parcels .stone-cards {
  transform: translateY(-50%) translateX(115%);
  visibility: hidden;
  pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
  #parcels .stone-cards { transition: none; }
}

/* ── the deck snaps ──────────────────────────────────────────────────────────────────────────
   A slide fades as it hands over, so mid-travel its text IS semi-transparent over the render —
   and the gate, sampling the document uniformly, kept catching exactly that moment: «Волинське
   пегматитове поле» at 1.24:1 with the map showing through a plate that was itself fading.

   The answer is not to make the fade cheaper. It is that a deck HAS resting positions and the
   page should hold the reader at them, which is also what «он просто меняется при скролле» means
   — you land on a slide, you do not park between two. Snap makes the resting state real instead
   of assumed, and then measuring the resting state is measuring what a reader actually sees.
   ───────────────────────────────────────────────────────────────────────────────────────────── */
html[data-deck] {
  scroll-snap-type: y mandatory;
}
html[data-deck] #deck-spacer {
  display: grid;
  grid-auto-rows: 100svh;
  /* THE LAST SLIDE HAS TO BE REACHABLE, AND WITH A HIDDEN TOOLBAR IT WAS NOT.
     The rows are 100svh — the SMALL viewport height, toolbar showing — so the document is
     `slides · svh` tall. The reader's window is `dvh`, which GROWS to the full screen once the
     toolbar hides. Maximum scroll is then `slides·svh − dvh`, and the last snap point is at
     `(slides−1)·svh`; those are only equal while svh = dvh. Measured at svh = 0.92·dvh: maximum
     scroll 4588 against a last snap point at 4656 — the contact slide could not be scrolled to at
     all, on the one device class where it matters.
     `calc(100dvh - 100svh)` is exactly the height the toolbar gave back, so maximum scroll lands
     on the last snap point precisely. It is 0 on any viewport with no dynamic toolbar, which is
     every desktop, so nothing changes there. */
  padding-bottom: calc(100dvh - 100svh);
}
html[data-deck] #deck-spacer > i {
  scroll-snap-align: start;
  scroll-snap-stop: always;
}
@media (prefers-reduced-motion: reduce) {
  html[data-deck] { scroll-snap-type: y proximity; }
}

/* The DOM wordmark is the accessible copy; the RENDER draws the visible one, behind the stone.
   Keeping both painted put the mark on top of the stone, which is the one place it must not be. */
html[data-deck] #entry .wordmark {
  /* `color: transparent`, not `opacity: 0`. The element must still lay out AND still count as
     visible, because src/textlayer.js reads its box and its font to place the echo — an anchor at
     zero opacity is one the layer skips, and the mark then does not exist at all. This paints
     nothing while remaining a real, selectable, screen-reader-visible heading. */
  color: transparent;
  pointer-events: none;
  position: fixed;
  inset: 0;
  display: grid;
  place-content: center;
  font-size: min(24vw, 17svh);
  line-height: 1;
  margin: 0;
}

/* The cards are static inside a fixed slide. `sticky` inside a fixed ancestor has nothing to
   stick to, so the boxes main.js measures moved under it and the stones jumped between frames. */
html[data-deck] #parcels .stone-cards {
  position: static;
  transform: none;
  grid-template-columns: 1fr;
}
html[data-deck][data-shelf='gone'] #parcels .stone-cards { transform: translateX(115%); }

/* THE PHONE'S HERO MARK — a laid-out, INVISIBLE anchor. The mark itself is drawn into the scene
   by src/textlayer.js, exactly as it is on the desktop: huge, soft, laterally split, sitting
   behind the stone as light rather than as a caption. «Вот эта вот версия с хроматической
   аберрацией, размытая такая. Вот оно точно так же там и должно быть.»

   It was flat grey DOM type for a while, and that was the wrong answer to the right complaint. The
   round before THAT drew the echo and could not see it, and the reason was not the gain: at scale
   0.86 the stone's silhouette is 1.13 in NDC-x on a 390 px screen — wider than the frame — so the
   echo had nowhere to show. Morion transmits nothing (absorption [5.4, 6.1, 6.6] at density 1.5),
   so behind the silhouette is genuinely black; what the desktop shows is the part of the echo that
   falls BESIDE the stone. The stone therefore comes down to 0.58 (see SLIDE_STONE in src/deck.js)
   and the echo shows around it.

   The element must keep a real box and stay `visible` — textlayer reads both — and must paint
   nothing itself, because the DOM always stacks above the canvas. */
.hero-mark { display: none; }
html[data-deck] .hero-mark {
  /* A NORMAL BLOCK WITH A REAL LINE BOX. As a centred grid the element measured 390x844 and the
     text inside it had no box of its own: frame.rect came back [-1, 0, 1, 0], zero height, ready
     and gained and drawn into nothing. */
  display: block;
  position: fixed;
  left: 0;
  right: 0;
  /* 41%, not 50%: dead centre puts the whole word inside the stone's silhouette and only the two
     outermost letters clear it. A little higher and the word crosses the stone's upper third —
     still behind it, and readable at both ends. */
  top: 41%;
  transform: translateY(-50%);
  text-align: center;
  margin: 0;
  font-family: var(--display);
  font-size: min(16vw, 12svh);
  line-height: 1;
  color: transparent;
  pointer-events: none;
  z-index: -1;
}
html[data-deck] #entry .wordmark { display: none; }

/* SLIDE 00 HAS NOTHING ON IT. «Вначале… камень, там текста даже нет.» The eyebrow, the tagline,
   the ledes and the meta table were all still laid out, and the meta table carries a 0.94 plate —
   which is the grey box sitting under the stone on the first screen. The whole copy block goes;
   it stays in the DOM for a crawler and a screen reader. */
html[data-deck] #entry .copy,
html[data-deck] #entry .scroll-cue {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* SPECIFICITY. `html[data-deck] .s p { color: var(--ink) }` — the phone body-copy rule — is
   (0,2,2) and beat `html[data-deck] .hero-mark` at (0,2,1), so the mark was painting in full ink
   ON TOP of the stone: exactly the thing it exists not to do. Measured, not guessed: computed
   colour was rgb(236,238,242) with the transparent rule sitting right there in the sheet.
   Scoped to the section and marked, because a colour that MUST NOT paint is not a preference. */
/* SPECIFICITY. `html[data-deck] .s p { color: var(--ink) }` is (0,2,2) and beats
   `html[data-deck] .hero-mark` at (0,2,1), so the anchor painted in full body ink ON TOP of the
   stone — the «текст поверх камня» complaint itself. Measured, not guessed: computed colour came
   back rgb(236,238,242) with the transparent rule sitting right there in the sheet. Marked,
   because a colour that MUST NOT paint is not a preference. */
html[data-deck] #entry p.hero-mark,
html[data-deck] .s p.hero-mark {
  color: transparent !important;
}


/* ── slide 05 on a phone: the case IS the slide ───────────────────────────────────────────────
   «Либо там статичные пять камней с громовками, средними ценами… И с рамкой контурной, очень
   малозаметной, белой.» The five cards are the subject, so they get the room.

   Measured on a 390x844 screen, the section wanted 1026 px of content in about 700 px of usable
   height: eyebrow 18 + heading 103 + ledes 206 + the spec table 305 + the button 49, and only then
   the 345 px case. That is why the cards were laid out at top -66, above the screen.

   The spec table and the second and third ledes come out of the layout and stay in the document —
   the same treatment slide 00's copy gets, so a crawler and a screen reader still read the whole
   section. What is left is the heading, one line saying what the three forms are, the five stones,
   and the way to ask for a price. */
/* The lede goes too. It is ONE paragraph carrying all three definitions — 206 px, five lines, on
   a phone a wall — and there is no way to trim a paragraph in half with CSS. With it in, the case
   ran 584-925 on an 844 px screen and the last two stones were below the fold; the heading already
   names the three forms, and the full text is still in the document for the desktop, a crawler and
   a screen reader. */
html[data-deck] #parcels .meta,
html[data-deck] #parcels .ledes,
html[data-deck] #parcels .ledes > :nth-child(n + 2) {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}


/* ── the per-glyph reveal is a DESKTOP effect ────────────────────────────────────────────────
   src/textfx.js places each element's reveal window from `(docTop - sectionTop) / vh` — how far
   the element's own section has entered the viewport. On the deck every section is `fixed` and
   stacked at the same place, so those two document positions are whatever the last measure() pass
   happened to catch, and the window lands correctly or not depending on when that was. Measured:
   after a plain reload the whole «Волинь, Клесів, Житомирщина» heading sat at opacity 0 and stayed
   there through 300 rendered frames and 1.4 s of real time — an invisible section title.

   It is not worth porting: the deck already has a reveal, and it is the slide's own cross-fade.
   `!important` because textfx writes these three properties inline every frame. */
html[data-deck] .fx-split .g {
  opacity: 1 !important;
  transform: none !important;
  clip-path: none !important;
  filter: none !important;
}

/* Slide 01's subject is WHERE the stones come from, and the thing that says it is the map. The
   spec table was sitting on top of the map and covering all of it but the edges. Its first row —
   «Волинське пегматитове поле / Топаз, геліодор, берил, моріон, фенакіт, флюорит» — is the two
   tallest lines in the table and it says what the lede directly above it already says, word for
   word. It comes out on the phone only; the other three rows (Клесів, Житомирщина, and the 117 kg
   topaz) are the ones carrying information the copy does not, and they stay. */
html[data-deck] #deposits .meta,
html[data-deck] #deposits .meta > :first-child {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* ── slides 02 and 03 did not fit the screen ──────────────────────────────────────────────────
   Measured at 390x844: #cut's copy ran 89-1197 and #catalogue's 89-1318, so the last table on one
   and most of the species panel on the other were simply below the fold, with nothing to say they
   were there. Two cuts, both on the phone only.

   #cut carries TWO tables of the same kind: `.readout`, which is live and changes as the stone is
   cut, and `.meta`, which is the static Tolkowsky reference (crown 34.5 degrees, pavilion 40.75,
   table 56 per cent). The live one is the slide's subject, so the reference one goes.

   #catalogue's `.stone-copy` is a 415 px paragraph — the mineralogy of the selected species. The
   name, the spec table and the deposit line carry the same facts in the form someone actually
   scans on a phone, so the prose goes and they stay. */
html[data-deck] #cut .meta,
html[data-deck] #catalogue .stone-copy {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The surviving tables go two-up. Five stacked label/value pairs at 66 px each is 330 px of a
   844 px screen for what is five short numbers; in two columns it is three rows and about 200,
   and the stone gets the band underneath. */
html[data-deck] #cut .readout,
html[data-deck] #catalogue .spec {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0.55rem 1.1rem;
}

/* Six pixels. #catalogue is the densest slide — eyebrow, heading, lede, five species buttons and
   the panel — and after the prose came out it still measured 850 against an 844 px screen, which
   clips the deposit line off the bottom of the panel. Taken out of the gap above the panel rather
   than out of any type size. */
html[data-deck] #catalogue .stone-panel { margin-top: 0; }
html[data-deck] #catalogue .species { margin-bottom: 0.35rem; }

/* ── where the words sit, now that nothing is printed on a slab ───────────────────────────────
   With the grounds gone the type no longer has to be kept OFF the render — it lies on it, and the
   only thing that matters is that it stays out of the object's middle. So each slide anchors its
   copy to the edge its object is not at, and the object gets the whole frame at nearly full
   exposure instead of being dimmed to 0.34-0.46 so a slab could be printed over it.

   The band experiment that preceded this is gone with them: it pushed the copy into the lower 62
   per cent and measured what was left, and on the catalogue slide what was left was EIGHTEEN
   pixels. Trying to find room for two things on a 390x844 screen was the wrong problem — they
   occupy the same room now. */
html[data-deck] #deposits  { align-content: start; }
html[data-deck] #cut       { align-content: end; }
html[data-deck] #catalogue { align-content: end; }

/* The tables lose their longest row. Measured on the phone: #cut's live readout is 303 px and
   #catalogue's spec 319, and in both the tallest row by a wide margin is «Огранка», whose value
   («Кабошон, орієнтований по іризації») wraps to three lines. Dropping it takes each table from
   three rows two-up to two, which is 170 px back on one slide and 160 on the other — the
   difference between an object band of 118 px and one of about 350.

   Not arbitrary: on #cut the cut's NAME is in the heading and the numbers that actually move as
   the stone is ground are crown, pavilion, facets and depth; on #catalogue the same value is
   already the second line of the stone's own name block. */
html[data-deck] #cut .readout > :nth-child(2),
html[data-deck] #catalogue .spec > :nth-child(5) {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* The case keeps its hairline and loses its fill — «с рамкой контурной, очень малозаметной,
   белой» is a border, and it was the only thing on the page that was already right. */
html[data-deck] #parcels .stone-card,
html[data-deck] #parcels .stone-cards {
  background-color: transparent;
}
html[data-deck] #parcels .stone-card {
  text-shadow: 0 1px 14px rgba(4, 5, 7, 0.98), 0 0 30px rgba(4, 5, 7, 0.9);
}
/* Same for the two banded blocks on the fire slide. */
html[data-deck] .figures,
html[data-deck] .fire-top,
html[data-deck] .display-lines,
html[data-deck] .stone-panel {
  background-color: transparent;
  border: 0;
  text-shadow: 0 1px 14px rgba(4, 5, 7, 0.98), 0 0 30px rgba(4, 5, 7, 0.92), 0 0 60px rgba(4, 5, 7, 0.7);
}

/* LESS TEXT, per slide. «Меньшим количеством текста.» With the slabs gone the render is the page,
   and every line that is not doing work is a line lying across a picture.
     01  the whole spec table — the lede above it already names Volyn, Klesiv and Zhytomyr and
         what comes out of each, and the table was sitting on the map.
     04  the measurement footnote. «різниця n на лініях B і G Фраунгофера» is how dispersion is
         defined, which belongs beside the numbers on a desktop and is a footnote on a phone.
   All of it stays in the document for the desktop, a crawler and a screen reader. */
html[data-deck] #fire .figures > :last-child {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* ═══════════════════════════════════════════════════════════════════════════════════════════
   THE PHONE HAS NO FILLS. This block is last in the file and it is `!important`, and both of
   those are deliberate.

   The first attempt at this set `background-color: transparent` on the same selectors higher up
   and changed NOTHING VISIBLE, which is worth writing down because it looked like it had worked.
   Measured afterwards, element by element, on all seven slides: .readout, .spec and .meta were
   still rgba(4, 5, 7, 0.94) — from a SECOND `html[data-deck] .meta, .readout, .spec` block
   further down the same file, identical specificity, later in source order, so it won. .figures
   and .fire-top were still solid #040507 — from `#fire .display-lines, #fire .figures`, which
   carries an id and therefore outranks anything selected by `html[data-deck] .figures`. And
   .cta, .species-btn, .copy and the masthead had never been touched at all.

   So: one rule, at the end, that no id can outrank. «На телефоне не будет фонов ебучих вот этих
   вот с заливкой» is not a preference to be weighed against a more specific selector.

   What replaces them is the text-shadow, which is declared on the same elements. The contrast
   gate cannot see a text-shadow (src/probe.js composites background-COLOR only), so it will score
   this page lower than it reads — the conservative direction.
   ═══════════════════════════════════════════════════════════════════════════════════════════ */
html[data-deck] .copy,
html[data-deck] .meta,
html[data-deck] .readout,
html[data-deck] .spec,
html[data-deck] .figures,
html[data-deck] .fire-top,
html[data-deck] .display-lines,
html[data-deck] .stone-panel,
html[data-deck] .stone-cards,
html[data-deck] .stone-card,
html[data-deck] .masthead,
html[data-deck] #fire .display,
html[data-deck] #fire [data-field='display'],
html[data-deck] #fire .eyebrow,
html[data-deck] #fire .meta {
  background-color: transparent !important;
  background-image: none !important;
  border-top-color: transparent !important;
  backdrop-filter: none !important;
}

/* The scrim under the copy goes with them — it is a gradient slab by another name. */
html[data-deck] .copy::before,
html[data-deck] .masthead::before {
  display: none !important;
}

/* Buttons keep a hairline and lose their fill. A rule you can see round a word is a control; a
   filled black pill over a render is a panel. */
html[data-deck] .cta,
html[data-deck] .cta-wrap a,
html[data-deck] .species-btn {
  background-color: transparent !important;
  border: 1px solid rgba(236, 238, 242, 0.22);
  text-shadow: 0 1px 12px rgba(4, 5, 7, 0.98), 0 0 26px rgba(4, 5, 7, 0.9);
}
html[data-deck] .species-btn[aria-pressed='true'] {
  border-color: rgba(236, 238, 242, 0.55);
}

/* NO TRANSITION ON THE FILL WE JUST REMOVED. .species-btn and .cta declare
   `transition: background-color 0.2s`, so `background-color: transparent` does not APPLY the
   colour, it starts a 200 ms animation towards it — and for those 200 ms the element really is
   painting a fill. The adaptive gate caught exactly that and reported it as a background: first
   rgb(4, 5, 7), then rgba(4, 5, 7, 0.31) on the next run, a value that moves between runs because
   it is a frame of an animation rather than a colour anyone chose. The same class of bug the
   contrast gate documents in tools/capture.mjs, met from the other side.
   A phone has no hover, so there is nothing here for a transition to smooth in the first place. */
html[data-deck] .species-btn,
html[data-deck] .cta,
html[data-deck] .cta-wrap a {
  transition: none !important;
}

/* Every piece of type that used to sit on a slab now carries the shadow instead. */
html[data-deck] .meta,
html[data-deck] .readout,
html[data-deck] .spec,
html[data-deck] .figures,
html[data-deck] .fire-top,
html[data-deck] .display-lines,
html[data-deck] .stone-panel,
html[data-deck] .stone-card,
html[data-deck] .masthead {
  text-shadow: 0 1px 14px rgba(4, 5, 7, 0.98), 0 0 30px rgba(4, 5, 7, 0.94), 0 0 60px rgba(4, 5, 7, 0.75);
}
