/* ==========================================================================
   AUDIO 1 — v8 — shared stylesheet
   Two palettes (paper / dark), section themes, seam dissolve, bleed grid,
   scroll-driven motion, tiered 3D placeholders. See DESIGN-v8.md — binding.
   ========================================================================== */

/* ---- fonts -------------------------------------------------------------- */
@font-face {
  font-family: "Archivo Fallback";
  src: local("Arial Bold"), local("Arial");
  size-adjust: 96%;
  ascent-override: 92%;
  descent-override: 24%;
  line-gap-override: 0%;
}
@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

/* ---- reset ---------------------------------------------------------------*/
*, *::before, *::after { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
img, svg, canvas, video { display: block; max-width: 100%; }
button, input, textarea { font: inherit; color: inherit; }
a { color: inherit; }
ul, ol { margin: 0; padding: 0; list-style: none; }
h1, h2, h3, p { margin: 0; }

/* ---- tokens --------------------------------------------------------------*/
/* One palette, one place. v8 declared these twice — a second :root at the
   bottom of the file quietly won every cascade — and three of its contrast
   comments were wrong in the flattering direction. Every ratio below was
   recomputed with the WCAG 2.x relative-luminance formula. */
:root {
  /* dark — this is the page */
  --void: #17130E;        /* ground. warm near-black, not cold #050505 */
  --carbon: #211B15;      /* raised panel */
  --pitch: #0C0906;       /* recessed band */
  --chalk: #EDE5D6;       /* 14.77:1 on --void */
  --ash: #A9A093;         /*  7.16:1 on --void */
  --ash-dim: #6F675C;     /*  3.32:1 — borders and 24px+ only, never body */
  --edge: #332C24;        /* hairline. decorative. never a focus ring */
  --edge-pitch: #453C31;  /* v13: hairline visible on --pitch (1.84:1, was 1.44) */

  /* paper — the exception you earn */
  --paper: #AC9F84;      /* 47% darker than v8 #DED3C0 — owner: "beige, significantly darker" */
  --paper-hi: #E9E1D2;
  --paper-lo: #CFC3AD;
  --ink: #17130E;        /* 7.08:1 on --paper */
  --ink-body: #241E16;   /* 6.32:1 on --paper */
  --ink-muted: #3A3227;  /* 4.83:1 on --paper — AA floor for body */
  --ink-fade: #4E4536;   /* 3.61:1 — 24px+ only, same role as before */
  --stone: #8E8269;      /* rules and bars. never text */

  /* v16 — ONE accent. This block held six hues (two dark reds, a paper red,
     a bright hover red, clay and clay-deep) plus beige in three tones, and
     "six accents pretending to be one" is most of why the page read as busy
     rather than designed. His own sites are one ground + one accent + greys.
     What is left: --on-air for lamps/rules/needles, --on-air-hot for anything
     carrying a label on dark, --on-air-ink as a GRAPHIC-only mark on paper.
     Everything else that used to be coloured is now ink, chalk or ash. */
  --on-air: #B23A31;      /* 3.59:1 on --void — LAMPS, RULES, NEEDLES. NEVER TEXT */
  --on-air-hot: #D65C52;  /* 4.65:1 on --void — anything carrying a label */
  --on-air-ink: #8C2E27;  /* measured 2.87:1 on --paper — GRAPHIC ONLY, NEVER TEXT */
  --link-ink: #3A2A14;   /* 5.29:1 on --paper, recomputed by hand — links are ink, not a fifth hue */

  /* type */
  --f-display: "Archivo", "Archivo Fallback", Arial, sans-serif;
  --f-text: "Inter", "Inter Fallback", Arial, sans-serif;
  --f-mono: "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace;

  /* scale. 34 → 108px on the hero, 14 → 15px on body: the ratio between the
     loudest and quietest thing on the page stays ~8. 2026-08-09: tokens were
     cut ~25% (owner: "the whole site is zoomed in too much"). v13 2026-08-10:
     another 12-21% at 1440 (owner, again: still zoomed in). v14 2026-08-10:
     "must look slightly zoomed out" — type −11/−12%, section pads −16/17%,
     seams −42%, insets −6%. Readability is the floor, not the zoom. */
  /* v16 — THE 25% ZOOM-OUT. He has asked for this four times (v10, v13, v14,
     v16) and each pass took 11-21%, which is why it kept coming back. Display
     type takes the full ×0.75 at both ends of every clamp. Reading sizes stop
     at their floor instead — body at 13px, meta at 11.5px — because 25% off
     14px is 10.5px and that is not a zoom-out, that is unreadable. The zoom
     therefore comes from display type, plate widths and vertical padding,
     which is where the bloat actually was. */
  --t-hero: clamp(1.40625rem, 0.8625rem + 2.14vw, 3.75rem);  /* 22.5 → 60  (v16 −25%) */
  --t-h2:   clamp(0.90625rem, 0.69rem + 1.01vw, 1.78125rem); /* 14.5 → 28.5 (v16 −25%) */
  --t-h3:   clamp(0.8125rem, 0.79rem + 0.14vw, 0.9375rem);   /*  13 →  15  (floor) */
  --t-lead: clamp(0.8125rem, 0.78rem + 0.14vw, 0.9375rem);   /*  13 →  15  (floor) */
  --t-body: clamp(0.84375rem, 0.83rem + 0.03vw, 0.875rem);   /* 13.5 →  14  (floor) */
  --t-meta: clamp(0.71875rem, 0.71rem + 0.03vw, 0.75rem);    /* 11.5 →  12  (floor) */
  --t-code: clamp(0.6875rem, 0.67rem + 0.06vw, 0.71875rem);  /*  11 → 11.5 (floor) */
  /* v15: the frame stamp. 10px floor, 11px cap — smaller than --t-meta on
     purpose, because a stamp is punched, not set. @1440 = 10.75px. */
  --t-stamp: clamp(0.625rem, 0.60rem + 0.08vw, 0.6875rem);  /*  10 →  11 */

  /* grid / rhythm — unequal on both axes, on purpose. The left inset carries
     the type, the right one is where things are allowed to run off. */
  /* v16: the insets go UP while everything else goes down — "in many places
     text is too close to the closest edges or borders". The right inset was
     the thin one (12px at 320) and that is exactly where copy was landing on
     the edge. Both small ends raised to clear the 16px minimum at phone size. */
  --inset-l: clamp(18px, 3.3vw, 76px);
  --inset-r: clamp(18px, 2.2vw, 44px);
  --inset: var(--inset-l);   /* alias for symmetric chrome: nav, mobile sheet */
  --gap: clamp(10px, 0.72vw, 15px);
  /* vertical padding takes the full ×0.75 — this is most of the 25% */
  --pad-paper: clamp(18px, 1.8vh + 0.52vw, 33px);   /* v16 −25% */
  --pad-dark:  clamp(22px, 2.4vh + 0.68vw, 43px);   /* v16 −25% */
  --space-section: clamp(15px, 2.1vh, 33px);        /* v16 −25% */
  /* the minimum air any text keeps from the edge of whatever contains it */
  --edge-min: 16px;
  /* v15 — the contact-sheet chrome. The rail is the one fixed thing on the
     left edge of the screen, so the page can never read as a floating column. */
  --rail-gut: 18px;
  --perf-h: 13px;                                   /* sprocket strip height */

  /* motion */
  --dur-tap: 120ms;
  --dur-ui: 240ms;
  --dur-enter: 420ms;
  --dur-long: 700ms;
  --dur-epic: 1200ms;
  --ease-out: cubic-bezier(.22, 1, .36, 1);
  --ease-io: cubic-bezier(.83, 0, .17, 1);
  --spring: linear(0, .0056, .0223, .0902 4.5%, .3557 9%, .7811 14%, .9629 16.5%,
            1.0932 19.4%, 1.1305 21.6%, 1.1213 23.6%, 1.0574 27.6%, .9946 31.6%,
            .9757 34.5%, .9739 39%, 1.0043 46.6%, 1.0088 51.7%, .9977 63.4%, 1);

  color-scheme: dark;
}
/* ---- grid spans, lifted out of inline style attributes ---------------------
   These were inline `style="grid-column:…"`, which outranks every stylesheet
   rule — so the mobile overrides further down could never fire (11 of them were
   dead). As classes they sit at the same specificity as those overrides but
   earlier in the file, so desktop holds and mobile wins. Do not re-inline these. */
.gc-1-10 { grid-column: 1 / 10; }
.gc-1-13 { grid-column: 1 / 13; }
.gc-1-3 { grid-column: 1 / 3; }
.gc-6-13 { grid-column: 6 / 13; }
.gc-1-5 { grid-column: 1 / 5; }
.gc-5-10 { grid-column: 5 / 10; }
.gc-3-7 { grid-column: 3 / 7; }
.gc-7-10 { grid-column: 7 / 10; }
.gc-10-13 { grid-column: 10 / 13; }
.gc-1-6 { grid-column: 1 / 6; }
.gc-1-7 { grid-column: 1 / 7; }
.gc-1-8 { grid-column: 1 / 8; }
.gc-1-9 { grid-column: 1 / 9; }
.gc-6-11 { grid-column: 6 / 11; }
.gc-7-12 { grid-column: 7 / 12; }
.gc-7-13 { grid-column: 7 / 13; }
.gc-8-13 { grid-column: 8 / 13; }
.gc-9-13 { grid-column: 9 / 13; }

html { overflow-x: clip; scrollbar-gutter: stable; scroll-behavior: auto; }
body {
  background: var(--void);
  color: var(--chalk);
  font-family: var(--f-text);
  font-size: var(--t-body);
  line-height: 1.62;
  -webkit-font-smoothing: antialiased;
}
@media (prefers-reduced-motion: no-preference) {
  html { scroll-behavior: smooth; }
}

/* section theme tokens — switch, never interpolate */
section[data-theme="paper"] {
  --bg: var(--paper); --bg-hi: var(--paper-hi);
  --fg: var(--ink); --fg-body: var(--ink-body); --fg-muted: var(--ink-muted);
  --fg-small-muted: var(--ink-muted);
  --fg-fade: var(--ink-fade); --link: var(--link-ink); --rule: var(--stone);
  --accent-ink: var(--on-air-ink); --accent: var(--on-air); --border: var(--ink-muted);
  --btn-fill: var(--ink); --btn-text: var(--paper-hi);   /* 13.28:1 */
  --focus: var(--ink);                                    /* 11.66:1 */
  background: var(--bg); color: var(--fg-body);
}
section[data-theme="dark"] {
  --bg: var(--void); --bg-hi: var(--carbon);
  --fg: var(--chalk); --fg-body: var(--ash); --fg-muted: var(--ash-dim);
  /* --fg-small-muted is for readable-but-secondary text below 24px: ash-dim
     (3.7:1) is illegal at that size, so small "muted" text on dark uses ash
     (7.9:1) instead — ash-dim stays reserved for borders/disabled/large-only. */
  --fg-small-muted: var(--ash);
  --fg-fade: var(--ash); --link: var(--chalk); --rule: var(--edge);
  --accent-ink: var(--on-air); --accent: var(--on-air); --border: var(--ash-dim);
  /* --on-air is 3.59:1 against --void and was the fill behind every primary
     CTA label on the site: the button failed AA for its own text. --on-air-hot
     is the same red pushed to 4.65:1. --on-air keeps the lamps and needles. */
  --btn-fill: var(--on-air-hot); --btn-text: var(--void);
  --focus: var(--chalk);                                  /* 14.77:1 */
  background: var(--bg); color: var(--fg-body);
}
section[data-theme] h1, section[data-theme] h2, section[data-theme] h3 { color: var(--fg); }
section[data-theme] { position: relative; z-index: 1; }

/* ---- type ------------------------------------------------------------- */
/* v14. The headline was capped at `max-width: 11ch` at EVERY viewport, so a
   68px h1 lived in a 473px column and the Slovenian hero wrapped to ten lines
   of two words. Measured before/after at 320/768/1024/1440/1920 in SL/EN/HR.
   `line-height` was 0.92 — smaller than the font-size, so lines collided — and
   `letter-spacing: -0.035em` (−2.96px at 1920) ate the word space, which is
   what made words read as fused. Display type gets a measure, not a straitjacket. */
h1 {
  font-family: var(--f-display); font-weight: 800; font-size: var(--t-hero);
  letter-spacing: -0.020em; word-spacing: 0.05em; line-height: 1.02; max-width: 19ch;
}
h2 {
  font-family: var(--f-display); font-weight: 800; font-size: var(--t-h2);
  letter-spacing: -0.016em; word-spacing: 0.04em; line-height: 1.08; max-width: 20ch;
}
h3 {
  font-family: var(--f-display); font-weight: 650; font-size: var(--t-h3);
  letter-spacing: -0.010em; line-height: 1.24; max-width: 26ch;
}
/* NEVER hyphenate display type. `hyphens: auto` is what produced "medi-jih" and
   "vmedi-" — Chromium hyphenates Slovenian happily and it reads as a typo.
   `overflow-wrap: break-word` stays as the 320px last resort: it only fires when
   a single word genuinely cannot fit its track, which no word in SL/EN/HR does
   at the current minimum of 30px. */
h1, h2 { hyphens: manual; -webkit-hyphens: manual; overflow-wrap: break-word; text-wrap: pretty; }
.lead { font-size: var(--t-lead); letter-spacing: -0.005em; line-height: 1.4; max-width: 40ch; }
.body-text { font-size: var(--t-body); line-height: 1.62; max-width: 62ch; }
.list-item { font-weight: 450; line-height: 1.45; max-width: 46ch; }
.mono {
  font-family: var(--f-mono); font-weight: 500; text-transform: uppercase;
  letter-spacing: 0.08em; line-height: 1.4; font-size: var(--t-meta); max-width: 40ch;
}

html[lang="sl"] h1, html[lang="hr"] h1,
html[lang="sl"] h2, html[lang="hr"] h2 { font-stretch: 92%; }
html[lang="en"] h1, html[lang="en"] h2 { font-stretch: 100%; }
@media (max-width: 420px) {
  h1, h2 { font-stretch: 86%; }
}

/* v14 headline reveal — a fader wipe, and it never touches the text nodes.
   The old version split the headline into per-line <span>s in JS. That split
   was non-deterministic (it compared the rect top of a BLOCK, which does not
   move when text wraps inside it), and consecutive line spans carried no
   whitespace between them, so the DOM came back as "26 let vmedijih.
   zdajpomagamopovedati". Words fusing on the front page is not a bug worth
   keeping an effect for. The wipe below reads as a channel opening on a desk —
   it is one clip-path on one element, costs nothing, and cannot corrupt text.
   No clip-path support (very old browsers) = the headline is simply there. */
.two-tone { display: block; }
@media (prefers-reduced-motion: no-preference) {
  .two-tone {
    clip-path: inset(-0.18em 0 100% 0);
    transform: translateY(0.10em);
    transition: clip-path 760ms var(--ease-io), transform 760ms var(--ease-out);
    will-change: clip-path;
  }
  .two-tone.in-view { clip-path: inset(-0.18em 0 -0.18em 0); transform: none; will-change: auto; }
}

/* ---- layout: grid / bleed ---------------------------------------------- */
/* No max-width. No margin-inline: auto. At 2560px each column is ~200px, so an
   h1 at 176px spanning 1/9 fills 1600px of the screen instead of sitting in a
   1440px box with 560px of nothing on each side. minmax(0,1fr) rather than 1fr
   because 1fr is minmax(auto,1fr) and any unbreakable string blows the track. */
.grid {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  gap: var(--gap);
  width: 100%;
  padding-inline: var(--inset-l) var(--inset-r);
  position: relative;
}
.grid > * { min-width: 0; }
.bleed { width: 100vw; margin-inline: calc(50% - 50vw); position: relative; }
/* `overflow: clip`, NOT `hidden`. hidden makes an element a SCROLL CONTAINER
   even when nothing overflows it, and a scroll container is what
   `animation-timeline: view()` resolves against. Every section on this site
   therefore became the timeline source for the motion inside it — a box whose
   scrollHeight equals its clientHeight and so never advances. Measured: the
   phone had exactly ONE distinct transform across a full-page scroll, and the
   raster, the wash and the rule were pinned at 0.94, 1.00 and 0.81. With clip
   the same scroll produces eleven. clip cuts identically; it just does not
   invent a scroller. This is why the motion read as choppy and unreactive: it
   was not running at all. */
.section { padding-block: var(--pad-paper); position: relative; overflow: clip; }
section[data-theme="dark"].section { padding-block: var(--pad-dark); }
@media (min-width: 900px) {
}
/* v14: the .tilt utility no longer rotates. Slants were applied to work tiles,
   service media and the social fan for no reason anyone could state; the only
   deliberate one left on the site is .web-hero .hero-browser. */
.tilt { transform: none; }
/* v15: the two vertical offsets that used to live in inline style="--oy:…"
   attributes on the services. Inline styles outrank every stylesheet rule —
   that is how 11 responsive rules became dead code on this codebase — so
   offsets are classes now, like the spans already are. */
.oy-72 { --oy: 72px; }

/* v16: the fixed left-edge frame rail was removed at the owner's request —
   "remove the vertical thing on the side where it tels u where u are". Its CSS,
   its JS and its IntersectionObserver are all gone; the in-flow .stamp per
   section still numbers the frames. --rail-w went with it. */

/* ---- v15: the frame stamp -------------------------------------------------
   Below 1100px the rail is gone, so each band prints its own stamp inline,
   above the heading. Same information, set as a punched caption. */
.stamp {
  display: flex; align-items: baseline; gap: 10px;
  font-family: var(--f-mono); font-size: var(--t-stamp);
  text-transform: uppercase; letter-spacing: 0.14em;
  color: var(--fg-small-muted);
  margin-bottom: 14px;
}
.stamp b { font-weight: 700; color: var(--fg); }
.stamp i { font-style: normal; opacity: .72; }
.stamp::after {
  content: ""; flex: 1 1 auto; height: 1px; background: currentColor; opacity: .28;
  transform: translateY(-3px);
}
@media (min-width: 1100px) { .stamp { display: none; } }

/* The sprocket strips themselves are drawn as pseudo-elements on the two
   places that are genuinely strips of frames — .work-rail-wrap and .seam. A
   standalone .perf utility class was written here first and then removed
   rather than left as dead CSS; --perf-h is the shared height. */

/* ---- skip link ------------------------------------------------------------*/
.skip-link {
  position: fixed; top: -60px; left: 12px; z-index: 200;
  /* --on-air is 3.59:1 on --void and this is text, so it gets --chalk and the
     red goes to the rule beside it */
  background: var(--void); color: var(--chalk); border-left: 3px solid var(--on-air);
  padding: 12px 20px; border-radius: 8px; font-family: var(--f-mono);
  font-size: var(--t-meta); text-transform: uppercase; letter-spacing: 0.08em;
  transition: top var(--dur-ui) var(--ease-out);
}
.skip-link:focus { top: 12px; }

/* ---- nav pill ------------------------------------------------------------*/
.nav-pill {
  position: fixed; top: 20px; left: var(--inset); right: var(--inset); z-index: 100;
  display: flex; align-items: center; justify-content: space-between;
  /* v18d: four links, not three — commercials has its own page now. The pill
     was capped at 760px, which wrapped every label onto two lines and broke
     "AUDIO 1" in half. Wider cap, tighter link padding, and nothing wraps. */
  height: 56px; max-width: calc(980px + var(--gap));
  border-radius: 999px; padding-inline: 8px 8px;
  /* v15: plain value first. Without color-mix() the second declaration is
     discarded and the pill keeps an opaque background instead of rendering
     transparent over whatever band is behind it. */
  background: var(--paper-hi);
  background: color-mix(in srgb, var(--paper-hi) 92%, transparent);
  backdrop-filter: blur(14px);
  transition: background-color var(--dur-ui) var(--ease-out),
              border-color var(--dur-ui) var(--ease-out),
              transform var(--dur-ui) var(--ease-out);
  border: 1px solid transparent;
}
.nav-pill { color: var(--ink); }
.nav-pill[data-theme="dark"] {
  background: var(--carbon);
  background: color-mix(in srgb, var(--carbon) 88%, transparent);
  border-color: var(--ash-dim);
  color: var(--chalk);
}
.nav-pill.hide { transform: translateY(-140%); }
.nav-pill__word {
  display: flex; align-items: center; gap: 8px; font-family: var(--f-display);
  font-weight: 700; font-size: 15px; letter-spacing: -0.01em; padding-inline: 12px;
  text-decoration: none; white-space: nowrap; flex: 0 0 auto;
  transition: opacity var(--dur-ui) var(--ease-out);
}
.nav-pill__word:hover { opacity: .78; }
/* Four bars beside the wordmark, which is a level meter whether or not anyone
   said so. It used to run a generic alternating loop on hover — motion that
   repeats regardless of what is happening, which is the tell.
   It reads the visitor's own scrolling now: --lvl is scroll SPEED, written by
   initNavMeter, and each bar sits at a different point on the scale the way a
   real meter's segments do. Stand still and it falls back to its floor. */
.nav-pill__bars span {
  transform-origin: 50% 100%;
  transform: scaleY(var(--bar, .42));
  transition: transform 90ms linear;
}
.nav-pill__bars span:nth-child(1) { --bar: calc(.34 + var(--lvl, 0) * .62); }
.nav-pill__bars span:nth-child(2) { --bar: calc(.30 + var(--lvl, 0) * .78); }
.nav-pill__bars span:nth-child(3) { --bar: calc(.38 + var(--lvl, 0) * .54); }
.nav-pill__bars span:nth-child(4) { --bar: calc(.26 + var(--lvl, 0) * .88); }
/* the top bar reddens as it pins, the way a meter warns you */
.nav-pill__bars span:nth-child(4) {
  background: color-mix(in srgb, var(--on-air-hot) calc(var(--lvl, 0) * 100%), var(--on-air));
}
@media (prefers-reduced-motion: reduce) {
  .nav-pill__bars span { transition: none; --bar: .55; }
}
.nav-pill__bars { display: flex; align-items: flex-end; gap: 2px; height: 17px; }
.nav-pill__bars span { display: block; width: 3px; background: var(--on-air); border-radius: 1px; }
.nav-pill__links { display: flex; align-items: center; gap: 2px; flex: 0 1 auto; min-width: 0; }
.nav-pill__links a {
  font-family: var(--f-text); font-weight: 550; font-size: 13.5px; text-decoration: none;
  padding: 10px 11px; border-radius: 999px; opacity: 0.82; white-space: nowrap;
  transition: opacity var(--dur-ui) var(--ease-out);
}
/* between the mobile sheet's cutoff and the width where four full labels fit,
   the pill drops to the short forms rather than wrapping or overflowing */
@media (min-width: 861px) and (max-width: 1160px) {
  .nav-pill__links a { font-size: 12.5px; padding: 10px 8px; letter-spacing: -0.01em; }
}
.nav-pill__links a:hover, .nav-pill__links a[aria-current="page"] { opacity: 1; }
/* 44px was a fixed width, not a floor, so the label had to fit English. It does
   not: Croatian "izbornik" measures 54px and was being cut off mid-word. The
   target stays 44px, the box grows to whatever the word needs. */
.menu-toggle {
  display: none; align-items: center; justify-content: center;
  min-width: 44px; height: 44px; padding-inline: 10px; white-space: nowrap;
  border: 0; background: transparent; border-radius: 999px;
  font-family: var(--f-mono); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em;
}
/* The nav links collapse into the sheet; the language fader does not. It is
   the one control a Croatian visitor needs in the first two seconds, and v8
   hid it on exactly the devices most of them arrive on. Insets tighten to
   12px so the word, the fader and the menu button all keep 44px targets:
   measured at 320px that is 86 + 132 + 44 = 262 inside 280 of content box.

   v19: this was 640px while the language picker hid at 860px, which left a
   220px band — every 768px tablet — where the links were still shown and
   overflowed the viewport, the picker was already gone, and the menu button had
   not appeared yet. On those widths there was no way to change language at all.
   One breakpoint governs both now; they cannot drift apart again. */
@media (max-width: 860px) {
  .nav-pill { left: 12px; right: 12px; padding-inline: 6px; }
  .nav-pill__links { display: none; }
  .nav-pill__word .nav-pill__bars { display: none; }
  .nav-pill__word { padding-inline: 8px; }
  .nav-pill .lang-fader { margin-inline: 0; }
  .menu-toggle { display: flex; }
}

/* ---- language fader -------------------------------------------------------*/
.lang-fader {
  position: relative; width: 132px; height: 44px; border-radius: 999px;
  background: var(--paper-lo);
  background: color-mix(in srgb, var(--ink) 8%, transparent); margin-inline: 6px;
  display: flex; align-items: center;
}
.nav-pill[data-theme="dark"] .lang-fader { background: var(--carbon); }
/* v18h — this was a 3px accent bar across the MIDDLE of the cap, and the cap
   sits directly behind the active label: it rendered as a line struck through
   "SLO". A fader cap does want a grip mark, so it moves to the bottom edge of
   the cap where it reads as the indicator it was meant to be. */
.lang-fader__cap::after {
  content: ""; position: absolute; left: 9px; right: 9px; bottom: 5px; height: 2px;
  background: var(--on-air); border-radius: 2px;
}
/* the checked one sits ON the light cap, so it takes the ink colour whatever
   the surrounding pill's theme is */
.lang-fader__radio[aria-checked="true"] { opacity: 1; color: var(--ink); font-weight: 700; }

/* ---- mobile sheet ----------------------------------------------------------*/
.mobile-sheet {
  position: fixed; inset: 0; z-index: 150; background: var(--void); color: var(--chalk);
  display: flex; flex-direction: column; justify-content: center; gap: 32px; padding: var(--inset);
  transform: translateY(-100%); transition: transform var(--dur-enter) var(--ease-out);
}
.mobile-sheet.open { transform: translateY(0); }
.mobile-sheet[hidden] { display: none; }
.mobile-sheet__close {
  /* Was a fixed 56px circle, which only ever fitted a five-letter word. German
     "schließen" needs about 80px at this size and simply ran outside the ring.
     It is a pill that starts as a circle: 56px minimum so the tap target and
     the round look survive, growing with whatever the language actually says. */
  position: absolute; top: 20px; right: var(--inset);
  min-width: 56px; height: 56px; padding-inline: 18px; border-radius: 999px;
  white-space: nowrap;
  border: 1px solid var(--ash-dim); background: transparent; color: var(--chalk);
  font-family: var(--f-mono); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em;
}
.mobile-sheet nav { display: flex; flex-direction: column; gap: 8px; }
.mobile-sheet nav a {
  font-family: var(--f-display); font-weight: 700; font-size: var(--t-h2); text-decoration: none; color: var(--chalk);
}

/* ---- seam ------------------------------------------------------------------*/
/* v14: seams are the fade between sections and they were too short and too
   linear — a two-stop linear ramp has a visible hard shoulder at each end, so
   the sections still read as butted against each other. Taller, and eased.
   The stops below are a smoothstep (3t²−2t³) sampled at eighths: the transition
   starts and ends imperceptibly and does its work in the middle, which is what
   a dissolve actually looks like. */
/* v18b — the "zoomed out" pass. Every number below was picked off the measured
   section table, not by feel, and each one is the largest safe cut in its band.
   Seams first: index carries five of these and websites three, at up to 150px
   of deliberately empty gradient each — 750px on one page, the height of a
   whole section, spent on transitions between sections. The wash still reads
   at 100px; it just stops being a section in its own right. */
.seam {
  position: relative; z-index: 0; pointer-events: none; height: clamp(56px, 8vh, 100px);
  overflow: hidden;
}
.seam--short { height: clamp(42px, 5.5vh, 68px); }
.seam__wash {
  position: absolute; inset: -1px 0;
  background: linear-gradient(to bottom,
    var(--from) 0%,
    color-mix(in srgb, var(--to)  5%, var(--from)) 18%,
    color-mix(in srgb, var(--to) 16%, var(--from)) 30%,
    color-mix(in srgb, var(--to) 32%, var(--from)) 40%,
    color-mix(in srgb, var(--to) 50%, var(--from)) 50%,
    color-mix(in srgb, var(--to) 68%, var(--from)) 60%,
    color-mix(in srgb, var(--to) 84%, var(--from)) 70%,
    color-mix(in srgb, var(--to) 95%, var(--from)) 82%,
    var(--to) 100%);
}
/* color-mix() is not in older Safari/Chromium. Without it every stop above is
   invalid and the browser falls back to the plain two-stop ramp below, which is
   exactly the old behaviour — degraded, never broken. */
@supports not (background: color-mix(in srgb, red 50%, blue)) {
  .seam__wash { background: linear-gradient(to bottom, var(--from) 0%, var(--from) 14%, var(--to) 94%); }
}
/* v15: a seam is the gap between two frames on a strip, so it gets the
   perforation. One repeating radial at the 50% line — the point where the two
   themes are exactly half mixed — so the transition has an object in it
   instead of being 144px of gradient with nothing to look at. */
/* A row of dots used to sit across the middle of every fade — a 22px-pitch
   repeat-x of 1.9px circles at 55%. That is the "little dots inside them" the
   owner has reported twice. Perforation marks the fold on paper, but here they
   sat ON the gradient and read as dirt on the transition rather than as a
   register mark.
   One hairline instead, fading out to both ends so it has no start and no stop,
   and only where the two bands genuinely differ — a fade already says
   everything a mark could. */
.seam::after {
  content: ""; position: absolute; left: 0; right: 0; top: 50%; height: 1px;
  transform: translateY(-50%);
  background: linear-gradient(to right,
    transparent 0%,
    color-mix(in srgb, var(--to) 34%, transparent) 26%,
    color-mix(in srgb, var(--to) 34%, transparent) 74%,
    transparent 100%);
  opacity: .5;
}
@supports not (background: radial-gradient(circle at 50% 50%, #000 0 2px, transparent 3px)) {
  .seam::after { display: none; }
}

/* v14: the fixed nav pill is 56px tall at top:20px, so anything that starts a
   page needs 76px + breathing room or the nav sits on top of the first line.
   404.html had no such offset and its label rendered behind the pill. */
main > section:first-child,
main > div:first-child { padding-block-start: max(var(--pad-dark), 112px); }
.hero-home, .social-hero { padding-block-start: 0; }
/* The rotated dot-mask layer that used to sit here (.seam__dots) was removed at
   the owner's request — "remove the dotted slanted pillars from the whole site".
   The wash above is the section transition on its own now. */

/* ---- waveform ----------------------------------------------------------- */
/* The .waveform-spine (fixed vertical scrub strip on the right edge) was
   removed at the owner's request 2026-08-09 — "remove the wavy vertical
   pillar next to the scroller". Its JS (initWaveformSpine, initWaveformScrub)
   and the HTML node went with it; its ticker ran forever, so the page also
   gained back a permanently-on rAF loop. The hero/contact waveforms stay. */

/* v13: the hero waveform is gone — the crowd is the hero's floor, and a
   full-width bar of ash bars above it doubled the noise. The contact
   waveform stays. */
.waveform-contact { width: 100%; max-width: none; aspect-ratio: 1440/220; touch-action: none; }
.waveform-contact .bar { fill: var(--stone); transition: fill var(--dur-tap) linear; }
section[data-theme="dark"] .waveform-contact .bar { fill: var(--edge); }
.waveform-contact .bar.hot { fill: var(--on-air); }

/* ---- states: buttons / links -------------------------------------------- */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 10px;
  min-height: 56px; padding: 0 28px; border-radius: 999px; border: 0; cursor: pointer;
  font-family: var(--f-text); font-weight: 550; letter-spacing: 0.01em; text-decoration: none;
  background: var(--btn-fill); color: var(--btn-text);
  transition: transform var(--dur-tap) var(--ease-out), box-shadow var(--dur-ui) var(--ease-out), background-color var(--dur-ui) var(--ease-out);
  box-shadow: 0 4px 0 rgba(0,0,0,0);
}
.btn:hover { box-shadow: 0 10px 22px rgba(0,0,0,.18); filter: brightness(1.06); }
section[data-theme="dark"] .btn:hover { background: var(--on-air-hot); box-shadow: 0 0 0 1px var(--chalk) inset; }
.btn:active { transform: scale(.965); transition-duration: var(--dur-tap); }
@media (prefers-reduced-motion: no-preference) {
  .btn:active { transition-timing-function: var(--spring); }
}

a.link {
  color: var(--link); text-underline-offset: 0.14em; text-decoration-line: underline;
  text-decoration-thickness: 1px; transition: text-decoration-thickness var(--dur-ui) var(--ease-out);
}
a.link:hover { text-decoration-thickness: 2px; }

:focus-visible {
  outline: 3px solid var(--focus, var(--chalk)); outline-offset: 3px; border-radius: inherit;
}

/* ---- placeholder textures (built, not scavenged) ------------------------ */
.bg-blueprint {
  position: absolute; inset: 0; z-index: 0;
  background-image:
    repeating-linear-gradient(0deg, var(--rule) 0 1px, transparent 1px 64px),
    repeating-linear-gradient(90deg, var(--rule) 0 1px, transparent 1px 64px);
  /* v18: was .16 on paper, where --rule is dark on a light field and the ruling
     read as a printed grid ACROSS the band rather than under it — part of the
     "lines through things". It fades out down the band too, so it is a ground
     the page starts on rather than a texture applied to all of it. */
  opacity: 0.075;
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, rgba(0,0,0,.35) 62%, transparent 100%);
          mask-image: linear-gradient(to bottom, #000 0%, rgba(0,0,0,.35) 62%, transparent 100%);
}
section[data-theme="dark"] .bg-blueprint { opacity: 0.10; }  /* v16: was .22 — texture on one plate reads as material, on every surface it reads as a filter */

.bg-grain {
  position: absolute; inset: 0; z-index: 0; pointer-events: none; opacity: 0.035; mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
}
section[data-theme="dark"] .bg-grain { opacity: 0.05; }

/* Real halftone crowd — a photograph screened to a dot pattern, 4800x1380,
   transparent background so it sits over whatever the section is doing.
   This replaced eight radial-gradient blobs pretending to be people; that
   version was the "2D stuff" that made the page read as clipart.
   White-dot variant on dark sections, black-dot on paper. */
.crowd-plate {
  position: relative; width: 100%; overflow: hidden;
  background: transparent;
}
.crowd-plate::before {
  content: ""; position: absolute; inset: 0;
  background-image: image-set(
    url("assets/crowd/crowd-halftone-invert.webp") type("image/webp"),
    url("assets/crowd/crowd-halftone-invert.png") type("image/png"));
  background-size: cover;
  background-position: 50% 68%;
  background-repeat: no-repeat;
  /* parallax handle — site.js writes --crowd-y */
  transform: translate3d(0, var(--crowd-y, 0), 0) scale(1.02);
  will-change: transform;
}
section[data-theme="paper"] .crowd-plate::before {
  background-image: image-set(
    url("assets/crowd/crowd-halftone.webp") type("image/webp"),
    url("assets/crowd/crowd-halftone.png") type("image/png"));
}
/* fade the crowd into the section above it rather than cutting hard */
.crowd-plate {
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 22%, #000 100%);
  mask-image: linear-gradient(to bottom, transparent 0%, #000 22%, #000 100%);
}
@media (prefers-reduced-motion: reduce) {
  .crowd-plate::before { transform: none; }
}
.crowd-plate .accent-dot {
  position: absolute; width: 6px; height: 6px; border-radius: 50%; background: var(--on-air);
  box-shadow: 0 0 8px var(--on-air);
}

/* studio-night plate placeholder (home 01 rail) */
/* v15: the work tiles are UNEXPOSED FRAMES, not dark rectangles. There is no
   real reel footage yet and the site must not pretend there is, so each frame
   carries a 6% ghost of the same negative the hero prints, a registration
   cross, and a hairline edge. It reads as a frame on a light table waiting for
   a print — which is exactly what it honestly is. The slate underneath says
   what belongs in it. Never a grey box, never a dashed outline. */
/* The ghost print is the BOTTOM background layer and the two gradients above
   it are 94% opaque, so the negative shows through at ~6% without needing a
   second element or a second opacity. ::before on this box is already the
   halftone screen — writing a second rule for it is the specificity trap this
   codebase has shipped twice, so the cross gets ::after and nothing else does. */
.studio-plate {
  position: relative; width: 100%; height: 100%;
  background-image:
    linear-gradient(160deg, rgba(12,10,8,.94) 0%, rgba(5,5,5,.94) 60%),
    radial-gradient(220px 140px at 70% 30%, rgba(178,58,49,.10), transparent 70%),
    url("assets/crowd/crowd-halftone-invert.webp");
  background-size: auto, auto, 220%;
  background-position: 0 0, 0 0, var(--crop, 40% 46%);
  background-repeat: no-repeat, no-repeat, no-repeat;
  overflow: hidden;
  box-shadow: inset 0 0 0 1px rgba(237,229,214,.12);
}
/* registration cross — 22px, dead centre. One imperfection a generator skips. */
.studio-plate::after {
  content: ""; position: absolute; left: 50%; top: 50%; width: 22px; height: 22px;
  transform: translate(-50%, -50%); z-index: 2; opacity: .5;
  background-image:
    linear-gradient(var(--ash), var(--ash)),
    linear-gradient(var(--ash), var(--ash));
  background-size: 1px 100%, 100% 1px;
  background-position: center, center;
  background-repeat: no-repeat, no-repeat;
}
.work-rail > .work-tile:nth-child(1) .studio-plate { --crop: 12% 40%; }
.work-rail > .work-tile:nth-child(2) .studio-plate { --crop: 34% 58%; }
.work-rail > .work-tile:nth-child(3) .studio-plate { --crop: 55% 36%; }
.work-rail > .work-tile:nth-child(4) .studio-plate { --crop: 72% 62%; }
.work-rail > .work-tile:nth-child(5) .studio-plate { --crop: 88% 44%; }
.studio-plate::before {
  content: ""; position: absolute; inset: 0; opacity: .5;
  background-image: radial-gradient(circle, var(--ash) 1px, transparent 1.4px);
  background-size: 5.5px 5.5px;
  mix-blend-mode: soft-light;
}

.object-plate { position: relative; display: flex; align-items: center; justify-content: center; }
.object-plate svg { width: 100%; height: 100%; }
.object-plate .oa-lamp { fill: var(--on-air); filter: drop-shadow(0 0 10px var(--on-air)); }

.record-spin { animation: spin 1.8s linear infinite; transform-origin: 50% 50%; }
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .record-spin { animation: none; } }

/* v14: the example frames were a dashed border around nothing — the exact
   "renders as a grey box" the brief forbids. The reels genuinely do not exist
   yet, so the frame now says so in the site's own language: a slate mark, a
   registration cross, and a fine grid, on the section's raised surface. It
   reads as an empty frame on a light table, which is what it honestly is. */
.example-frame {
  border: 1px solid var(--border); position: relative; aspect-ratio: 16/10;
  background: var(--bg-hi);
  background:
    linear-gradient(0deg, var(--bg-hi), var(--bg-hi)),
    repeating-linear-gradient(0deg, transparent 0 23px, color-mix(in srgb, var(--border) 45%, transparent) 23px 24px),
    repeating-linear-gradient(90deg, transparent 0 23px, color-mix(in srgb, var(--border) 45%, transparent) 23px 24px);
  background-blend-mode: normal;
  overflow: hidden;
  transition: border-color var(--dur-ui) var(--ease-out), transform var(--dur-ui) var(--spring);
}
/* v15: the same negative, printed into the empty frame at 6%. The surface
   layer drops to 94% opacity so the ghost reads through it — one file, one
   decode, already in cache from the hero. Falls back to the flat surface where
   color-mix() is missing, which is exactly the v14 rendering. */
@supports (background: color-mix(in srgb, red 50%, transparent)) {
  .example-frame {
    background:
      linear-gradient(0deg,
        color-mix(in srgb, var(--bg-hi) 94%, transparent),
        color-mix(in srgb, var(--bg-hi) 94%, transparent)),
      repeating-linear-gradient(0deg, transparent 0 23px, color-mix(in srgb, var(--border) 45%, transparent) 23px 24px),
      repeating-linear-gradient(90deg, transparent 0 23px, color-mix(in srgb, var(--border) 45%, transparent) 23px 24px),
      url("assets/crowd/crowd-halftone.webp");
    background-size: auto, auto, auto, 260%;
    background-position: 0 0, 0 0, 0 0, var(--crop, 46% 42%);
    background-repeat: no-repeat, repeat, repeat, no-repeat;
  }
  section[data-theme="dark"] .example-frame {
    background-image:
      linear-gradient(0deg,
        color-mix(in srgb, var(--bg-hi) 94%, transparent),
        color-mix(in srgb, var(--bg-hi) 94%, transparent)),
      repeating-linear-gradient(0deg, transparent 0 23px, color-mix(in srgb, var(--border) 45%, transparent) 23px 24px),
      repeating-linear-gradient(90deg, transparent 0 23px, color-mix(in srgb, var(--border) 45%, transparent) 23px 24px),
      url("assets/crowd/crowd-halftone-invert.webp");
  }
}
.examples-grid .example-frame:nth-child(1) { --crop: 10% 40%; }
.examples-grid .example-frame:nth-child(2) { --crop: 36% 56%; }
.examples-grid .example-frame:nth-child(3) { --crop: 62% 38%; }
.examples-grid .example-frame:nth-child(4) { --crop: 86% 50%; }
/* registration cross, dead centre — the mark a frame gets before anything is
   in it. One imperfection a generator would not think to add. */
.example-frame::before {
  content: ""; position: absolute; left: 50%; top: 50%; width: 26px; height: 26px;
  transform: translate(-50%, -50%);
  background:
    linear-gradient(var(--border), var(--border)) center / 1px 100% no-repeat,
    linear-gradient(var(--border), var(--border)) center / 100% 1px no-repeat;
  opacity: .8;
}
.example-frame::after {
  content: attr(data-mark);
  position: absolute; right: 10px; bottom: 8px;
  font-family: var(--f-mono); font-size: var(--t-code); letter-spacing: .08em;
  color: var(--fg-small-muted);
}
.example-frame:hover { border-color: var(--fg-small-muted); transform: translateY(-3px); }
.example-frame--vert { aspect-ratio: 9/16; }

/* v14 cascade fix. `.frame-box` sits ~70 lines BELOW .studio-plate
   in this file and its `background` shorthand has the same specificity, so it
   won every one of those cascades — every plate on the site was rendering as a
   flat --bg-hi rectangle and the textures were dead code. The background is now
   a fallback that only applies when no plate class is present. */
.frame-box { aspect-ratio: var(--ar, 4/3); overflow: clip; position: relative; }
/* ---- v15: aspect-ratio fallback -------------------------------------------
   Every plate on this site is an empty div sized entirely by aspect-ratio. On
   a browser without it (pre-88 Chromium, pre-15 Safari — "really old laptops",
   which the owner asked for by name) those divs collapse to zero height and
   the page loses its pictures. The old padding-top trick restores them; the
   ratios are the ones actually in use, and the modern rule above wins wherever
   aspect-ratio exists because @supports gates this whole block off. */
@supports not (aspect-ratio: 1 / 1) {
  .frame-box, .example-frame, .laptop-seq, .waveform-contact { height: 0; }
  .frame-box::after, .example-frame::after { content: none; }
  .frame-box { padding-top: 75%; }                 /* 4/3  default */
  .services .service-media { padding-top: 80%; }   /* 5/4  */
  .services .service-media.object-3d { padding-top: 75%; }
  .services .service-media.shot-frame { padding-top: 56.25%; }  /* 16/9 */
  .services .service-media.object-plate { padding-top: 125%; }
  .web-hero .hero-browser { padding-top: 62.5%; }  /* 16/10 */
  .web-existing .slab-wrap .frame-box { padding-top: 56.25%; }
  .laptop-stage .plate-wrap .frame-box { padding-top: 56.25%; }
  .example-frame { padding-top: 62.5%; }
  .laptop-seq { padding-top: 71.43%; }
  .waveform-contact { padding-top: 15.28%; }
  .work-rail > .work-tile:nth-child(1) .frame-box { padding-top: 133.33%; }
  .work-rail > .work-tile:nth-child(2) .frame-box { padding-top: 100%; }
  .work-rail > .work-tile:nth-child(3) .frame-box { padding-top: 125%; }
  .work-rail > .work-tile:nth-child(4) .frame-box { padding-top: 100%; }
  .work-rail > .work-tile:nth-child(5) .frame-box { padding-top: 56.25%; }
  .social-feel .fan .frame-plate-box { height: auto; padding-top: 177.78%; }
}
.frame-box:not([class*="-plate"]):not(.browser-plate) { background: var(--bg-hi); }

/* ==========================================================================
   HOME
   ========================================================================== */
/* The stage is sticky over 168svh of travel — that travel is the timeline the
   console scrub, the crowd parallax and the tally strike all ride on. v8 set
   min-height:auto here and then wondered why the scrub didn't move. */
/* v14: was 120svh, which reserved 1296px at 1920 for 1009px of content —
   239-287px of measured dead air. A hero should be tall because of what is in
   it, not because of a viewport unit. Floor keeps it commanding on a phone. */
/* ---- v15b: the dissolve had 22px of scroll ---------------------------------
   MEASURED, and it is the same bug as the laptop's 100px, on the signature
   effect of the whole site. initDissolvePlate reads its progress from
   stageProgress(.hero-home) because .hero-stage is sticky, and stageProgress is
   -rect.top / (height - innerHeight). The section was min(92svh, 820px) and
   rendered 922px against a 900px viewport, so the crowd's entire dissolve ran
   across TWENTY-TWO PIXELS of scroll and then sat frozen at t = 1 for the rest
   of the page. Instrumented proof: the draw path was entered exactly ONCE
   during a full scroll through the hero.

   170svh capped at 1500px gives 600px of travel at a 900px viewport, and the
   wave's live window (0.06 -> 0.90) gets 504px of it. The type band is pinned
   to the foot of the sticky stage, so it stays put while the photograph comes
   apart behind it — which is the thing the owner asked for and has never once
   actually rendered. */
/* v16: was 170svh/1500px. This height is scroll RUNWAY for the sticky stage,
   not layout — but 1500px of runway is 900px of page you scroll past to move
   one animation. But this height is also the dissolve's TIMELINE: travel is
   min-height minus the 100svh sticky stage, so trimming it to 1000px left
   100px of travel and the crowd finished dissolving before you had scrolled
   past the headline — measured 0 lit pixels from 6% of the page onward, which
   is exactly the owner's "u cant even see the people disolving animation".
   1420px keeps ~520px of travel. The 25% comes from type, padding and plates,
   not from an animation's runway. */
.hero-home { min-height: min(145svh, 1285px); }
@supports not (position: sticky) { .hero-home { min-height: min(80svh, 615px); } }
@media (max-width: 900px) {
  /* phones do not get 1.7 screens of hero before the first line of copy */
  .hero-home { min-height: min(112svh, 900px); }
}
/* v15b: the type block moves to the FOOT of the stage. Measured cause: with the
   photograph filling the whole frame, chalk headline text sat directly on white
   halftone dots — the backdrop's median luminance was fine (15.16:1) but its
   bright dots reached 2.47:1, so the headline read as speckle. Averaging the
   plate down would have turned the crowd to mush. Instead the top of the frame
   is now pure photograph, edge to edge and untouched, and all the type lives in
   one knocked-back band along the bottom. The air above the type is doing the
   thing air is supposed to do: isolating the picture. */
.hero-stage {
  position: sticky; top: 0; height: 78svh; display: flex; flex-direction: column;
  justify-content: flex-end; padding-top: clamp(48px, 6vh, 68px);
  overflow: hidden;
}
/* v15: the schematic canvas sat at .30 over a mostly-empty hero. It now sits
   under a full-frame photograph, so it drops to .16 — it is a texture in the
   shadows, not a second image competing with the first. */
.hero-canvas-wrap { position: absolute; inset: 0; z-index: 0; opacity: .16; mix-blend-mode: screen; }
.hero-canvas-wrap canvas, .hero-canvas-wrap img { width: 100%; height: 100%; object-fit: cover; }
.hero-content {
  position: relative; z-index: 3; padding-inline: var(--inset-l) var(--inset-r);
  padding-bottom: clamp(20px, 4vh, 48px);
}
/* ---- v15b: the title card -------------------------------------------------
   A contact sheet does not print its caption on the emulsion — the caption sits
   on a band and the emulsion under it is knocked back. This is that band: hard
   top edge, full bleed left and right, an on-air hairline along the edge so it
   reads as a deliberate crop rather than a soft scrim someone smeared over a
   photo. The crowd stays visible inside it at ~12%, like ink under a wash.

   MEASURED: worst backdrop pixel behind the headline is L = 0.2898. Composited
   under rgba(23,19,14,.88) that becomes L = 0.0197, and #EDE5D6 on it is
   12.4:1 — versus 2.47:1 before. Verified against rendered pixels, not tokens. */
/* v16h — THIS WAS THE BOX. Owner: "fix the box and overlay over the animations
   on the top of both frontpage and social media page. the animation should be
   clear and totally visible what it is and whats happening."
   It was an 88%-opaque panel spanning the full width from the headline down,
   with a 1px --on-air rule across its top edge. Sampling the rendered page
   pixels at x=1200 found it exactly: a hard colour break at y=476 with the row
   reading #AC382F, and the crowd's contrast collapsing from ~400 to ~40 below
   it. A literal box with a red lid, laid over the animation.

   The type still needs a backdrop — --ash on bright halftone measured 1.40:1
   here once. So it keeps one, but as a gradient that fades in from nothing
   rather than a panel that starts with a rule: no edge to see, and the crowd
   above it keeps full strength. */
.hero-content::before {
  content: ""; position: absolute; z-index: -1;
  left: 0; right: 0; top: calc(-1 * clamp(56px, 9vh, 120px)); bottom: -100vh;
  background: linear-gradient(to bottom,
    rgba(23, 19, 14, 0) 0%,
    rgba(23, 19, 14, .34) 34%,
    rgba(23, 19, 14, .72) 62%,
    rgba(23, 19, 14, .86) 100%);
  pointer-events: none;
}
/* the emulsion fades into the band over 60px rather than stopping dead, so the
   hairline reads as a crop mark and not as the lid of a box */
/* v16h: the separate 60px fade-in is gone — the card is a gradient now, so it
   already fades in. Two stacked fades were part of what made the band read as
   an object. */
.hero-content::after { content: none; }
.hero-mono { display: flex; align-items: center; gap: 10px; margin-bottom: 18px; }
.live-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--on-air); box-shadow: 0 0 8px var(--on-air); }
@media (prefers-reduced-motion: no-preference) { .live-dot { animation: tally 2.4s steps(2, jump-none) infinite; } }
@keyframes tally { 0%, 49% { opacity: .55; } 50%, 100% { opacity: 1; } }

/* v15b: text no longer needs to fight the art — it sits on the title card, so
   the shadows are gone. They were softening the display face for no gain. */
.hero-home h1 { text-shadow: none; }
.hero-home .lead { color: var(--chalk); text-shadow: none; }
/* Diagonal scrim: heavy where the headline lands, gone by the right edge so
   the crowd stays at full strength on the side nothing sits on. */
/* v15: two layers now that the photograph fills the frame. The diagonal keeps
   the headline legible bottom-left; the vertical wash keeps the nav readable at
   the top and hands the band off to the ticker at the bottom without a cut. */
.hero-stage::after {
  /* v16d (owner: "fix the box and overlay over the animations… the animation
     should be clear and totally visible what it is and what's happening").
     Was a 100deg wash at .90 alpha plus a vertical one at .86 top and bottom —
     three heavy scrims over the whole frame, which read as a lighter box with
     hard edges pasted on the crowd and left the people unreadable.
     Now: one soft wash, weighted to the lower-left where the headline actually
     sits, and nothing at all over the upper right where the crowd is the
     subject. The type keeps its contrast; the picture gets to be a picture. */
  content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none;
  background:
    linear-gradient(104deg,
      rgba(10,8,6,.72) 0%, rgba(10,8,6,.42) 26%, rgba(10,8,6,.10) 52%, rgba(10,8,6,0) 72%),
    linear-gradient(to bottom,
      rgba(23,19,14,.30) 0%, rgba(23,19,14,0) 22%, rgba(23,19,14,0) 62%, rgba(23,19,14,.55) 100%);
}

/* The diagonal is the composition: tally top-left, lead + CTA top-right,
   the headline landing bottom-left on top of the crowd. Explicit rows, so the
   DOM keeps h1-first for reading order while the eye gets the corner-to-corner. */
@media (min-width: 900px) {
  .hero-home .hero-mono      { grid-column: 1 / 4;  grid-row: 1; }
  .hero-home .lead           { grid-column: 8 / 13; grid-row: 1; margin-top: 0; max-width: 40ch; }
  .hero-home .btn            { grid-column: 8 / 11; grid-row: 2; margin-top: clamp(16px, 2.25vh, 26px); justify-self: start; }
  .hero-home h1              { grid-column: 1 / 9;  grid-row: 3; margin-top: clamp(22px, 4.5vh, 66px); }
}
@media (max-width: 899px) {
  /* .hero-content carries the 12-col .grid utility. The 900px block above places
     each child on explicit columns; without a span here they fall back to
     grid-column:auto — a single ~26px cell — which stacked the headline one
     character per line. Every hero child spans the full track below 900px. */
  .hero-home .hero-mono,
  .hero-home h1,
  .hero-home .lead,
  .hero-home .btn { grid-column: 1 / -1; }
  .hero-home .btn { justify-self: start; }
  .hero-home .lead { margin-top: 22px; }
  .hero-home .btn { margin-top: 28px; }
}

/* The crowd is a floor the headline stands on, at full strength. v8 ran it at
   40% opacity and half brightness over cream, which is grey mush. v11 dropped
   the strip 46vh -> 37vh ("less zoomed in"); v12 cut it to 28vh; v13 grows it
   back to 36vh on purpose — a taller floor shows the heads and raised arms
   (the owner's people complaint), and with the crop anchored at 50% 68% the
   front row still forms the floor under the headline. */
/* v15 — THE IDEA. The crowd stops being a 36vh strip along the bottom and
   becomes the whole frame: the hero IS the photograph, printed edge to edge,
   and the type sits on it. This is the first and largest print of the one
   negative the site owns; every other band is a smaller crop of the same file
   (already preloaded, so this costs no new bytes).
   Crop moves 50% 68% -> 50% 34% because at full height the old anchor put the
   front row's heads under the headline instead of the whole crowd behind it. */
.hero-crowd-bleed {
  position: absolute; inset: 0; height: auto; z-index: 1; opacity: 1;
}
.hero-crowd-bleed.crowd-plate::before { background-position: 50% 34%; }
/* was `.hero-crowd-bleed .crowd-plate::before` — a descendant selector, and the
   two classes are on the same element, so it matched nothing and the hero photo
   never got the grade. Only visible now on the no-canvas fallback, but it should
   still be right there. */
/* v15b: grayscale(1) was a no-op — the source plate is already a 1-bit black
   and white halftone — and it was forcing a filter pass over a full-viewport
   image on every composite. The contrast/brightness grade is kept because it
   does change the print, and it is cheap next to the greyscale conversion. */
.hero-crowd-bleed.crowd-plate::before { filter: contrast(1.12) brightness(1.18); }  /* v16d: was 1.35/.92 — the crowd was too dark to read as people */
/* the print fades in under the nav and holds full strength from a third down,
   so there is no hard top edge and the nav pill never sits on dot texture */
.hero-crowd-bleed.crowd-plate {
  -webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,.55) 0%, rgba(0,0,0,.85) 10%, #000 26%, #000 100%);
          mask-image: linear-gradient(to bottom, rgba(0,0,0,.55) 0%, rgba(0,0,0,.85) 10%, #000 26%, #000 100%);
}

/* ---- v15: crop marks ------------------------------------------------------
   Four registration corners inset 22px. This is the evidence of a hand: a
   generator does not put printer's marks on a hero, and they are what makes
   the band read as a frame rather than a background image. Two gradients per
   corner, one element, no images. */
/* v16: crop marks removed. They were a third decorative layer on surfaces that
   already carry a stamp and a grid, and stacked furniture is most of why the
   page reads as AI-made — an AI page is one where every element is trying.
   The stamp stays as the single motif. */
.crop-marks { display: none; }
.crop-marks::before, .crop-marks::after {
  content: ""; position: absolute; width: 18px; height: 18px;
  border: 1px solid var(--fg-small-muted); opacity: .55;
}
.crop-marks::before { left: 0; top: 0; border-right: 0; border-bottom: 0; }
.crop-marks::after { right: 0; bottom: 0; border-left: 0; border-top: 0; }
@media (max-width: 640px) { .crop-marks { inset: 12px; } .crop-marks::before, .crop-marks::after { width: 12px; height: 12px; } }

.work { padding-top: var(--pad-paper); }
.work h2 { grid-column: 1 / 6; margin-bottom: 28px; }
/* v13: hairline shelf — the rail reads as a mounted strip, not five tiles
   floating on void */
/* v15: the shelf becomes a strip of film. Two sprocket rows, drawn with one
   repeating radial each — no images, no extra elements, one paint. This is the
   idea made literal at the one place on the site that is genuinely a sequence
   of frames. */
.work-rail-wrap { position: relative; border-top: 1px solid var(--edge); border-bottom: 1px solid var(--edge); }
.work-rail-wrap::before, .work-rail-wrap::after {
  content: ""; position: absolute; left: 0; right: 0; height: var(--perf-h);
  z-index: 3; pointer-events: none;
  background:
    radial-gradient(circle at 50% 50%, var(--bg) 0 2.4px, transparent 2.6px)
      0 0 / 15px var(--perf-h) repeat-x,
    linear-gradient(var(--edge), var(--edge));
  opacity: .8;
}
.work-rail-wrap::before { top: 0; }
.work-rail-wrap::after { bottom: 0; }
@supports not (background: radial-gradient(circle at 50% 50%, #000 0 2px, transparent 3px)) {
  .work-rail-wrap::before, .work-rail-wrap::after { background: var(--edge); opacity: .4; }
}
/* v15 — the owner asked for every scroller on the site to be custom. Firefox
   gets scrollbar-width/color (already set); WebKit and Blink get this. */
.work-rail::-webkit-scrollbar { height: 8px; }
.work-rail::-webkit-scrollbar-track { background: transparent; }
.work-rail::-webkit-scrollbar-thumb {
  background: var(--ash-dim); border-radius: 0;
  border-block: 3px solid transparent; background-clip: padding-box;
}
.work-rail::-webkit-scrollbar-thumb:hover { background: var(--ash); background-clip: padding-box; }
.work-rail {
  /* v14: gap was 16px while every tile carried an 8px outline-offset, so the
     mounts met in the middle and the tiles read as touching. 44px clears both
     mounts with 28px of air left. Bottom padding makes room for the slate. */
  display: flex; gap: 44px; align-items: flex-end;
  overflow-x: auto; scroll-snap-type: x proximity;
  padding-left: var(--inset-l); padding-right: 18vw;
  /* v15: the two sprocket rows are 13px each and sit inside this box, so the
     padding has to clear them or the strip crosses the frames. Bottom also
     carries the slate (15px offset + two mono lines). */
  padding-top: calc(var(--perf-h) + 14px);
  padding-bottom: calc(var(--perf-h) + 66px);
  scrollbar-width: thin; scrollbar-color: var(--ash-dim) transparent;
  cursor: grab;
}
.work-rail:active { cursor: grabbing; }
.work-tile {
  flex: 0 0 auto; position: relative; scroll-snap-align: start;
  transform: none; transition: transform var(--dur-ui) var(--spring), box-shadow var(--dur-ui) var(--ease-out);
  box-shadow: 0 12px 24px rgba(0,0,0,.28);
  /* v13: the contact sheet gets its light table back — a hairline mount */
  outline: 1px solid var(--ash-dim); outline-offset: 6px;
}
/* Five tiles, five different sizes and five different crops. A contact sheet
   is not a card grid — nothing here repeats. --ar inherits into .frame-box.
   v12: all five cut ~25% with the site-wide scale-down. */
/* v14: five widths and five crops stay — a contact sheet is not a card grid and
   nothing here repeats. What changed is that they now hang from a common top
   edge instead of three different vertical alignments, so the row reads as a
   strip that was mounted rather than tiles that were dropped. The rail is
   bottom-aligned, which puts every slate on one baseline while the tops stay
   uneven — the symmetry is where it carries information, not everywhere. */
.work-rail > .work-tile:nth-child(1) { width: min(255px, 46vw);  --ar: 3/4;  }
.work-rail > .work-tile:nth-child(2) { width: min(158px, 34vw);  --ar: 1/1;  }
.work-rail > .work-tile:nth-child(3) { width: min(201px, 40vw);  --ar: 4/5;  }
.work-rail > .work-tile:nth-child(4) { width: min(147px, 30vw);  --ar: 1/1;  }
.work-rail > .work-tile:nth-child(5) { width: min(315px, 52vw);  --ar: 16/9; }
.work-tile:hover, .work-tile:focus-within { transform: translateY(-4px); box-shadow: 0 22px 34px rgba(0,0,0,.32); }
.work-tile:active { transform: translateY(-1px) scale(.994); transition-duration: var(--dur-tap); transition-timing-function: var(--ease-out); }
/* v14 slate — the tiles are placeholders because the reels do not exist yet, so
   each one states what it is instead of being a dark rectangle. Sits under the
   mount on its own baseline; the rule is the edge of a light table. */
.work-slate {
  position: absolute; left: 0; right: 0; top: calc(100% + 15px);
  display: grid; grid-template-columns: auto 1fr; column-gap: 10px; row-gap: 2px;
  padding-top: 7px; border-top: 1px solid var(--edge);
}
.work-tile .frame-num {
  grid-row: 1 / 3; align-self: start;
  color: var(--on-air-hot); font-family: var(--f-mono);
  font-size: var(--t-meta); letter-spacing: .06em; line-height: 1.3;
}
.work-slate__kind {
  font-family: var(--f-display); font-weight: 650; font-size: var(--t-h3);
  line-height: 1.1; color: var(--fg); letter-spacing: -0.01em;
}
.work-slate__spec {
  font-family: var(--f-mono); font-size: var(--t-code); letter-spacing: .05em;
  color: var(--fg-small-muted); line-height: 1.3;
}
.work-note { grid-column: 6 / 11; align-self: end; margin-bottom: 28px; color: var(--fg-body); }
@media (max-width: 900px) { .work-note { grid-column: 1 / -1; margin: 0 0 22px; } }
.drag-hint { grid-column: 1/6; margin-top: 8px; color: var(--fg-small-muted); }

.intro { position: relative; }
/* v14: the -7vh overlap was meant to break the stack, but .section has
   overflow:hidden, so the top of the intro headline was clipped off against the
   section above it. Overlap is not worth a beheaded headline — the sections now
   meet through the seam gradient instead. */
/* v15: 2/8 -> 1/7. The text starts at the left inset, so the band touches the
   edge of the sheet instead of being indented one column into it. */
/* The plate that used to sit in columns 7-13 is gone — the owner did not want a
   photograph under the hero. The statement takes the width back rather than
   leaving half a band of beige: 1/10 with a wider measure, so it reads as a
   pull quote set across the sheet instead of a paragraph orphaned on the left. */
.intro .intro-inner { grid-column: 1 / 10; position: relative; z-index: 2; margin-top: 0; align-self: center; }
.intro .intro-inner .body-text { font-size: var(--t-h2); line-height: 1.18; color: var(--fg); max-width: 34ch; padding-left: 26px; position: relative; }
.fader-line {
  position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--on-air); transform-origin: top; transform: scaleY(0);
}
/* MOTION 3 of 3 — the rule beside the statement draws as you move through the
   band, like a fader being ridden up.
   This used to be a JS ticker: an IntersectionObserver, a measure that read
   getBoundingClientRect every frame, and a style write. Where scroll-driven
   animations exist the browser does the same job off the compositor with no
   main-thread work at all, so initFaderLine() now stands down and this takes
   over. The JS is still there for browsers without the timeline. */
@keyframes fader-ride { from { transform: scaleY(0); } to { transform: scaleY(1); } }
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .fader-line {
      animation: fader-ride linear both;
      animation-timeline: view(block);
      animation-range: entry 0% cover 62%;
    }
  }
}
@media (max-width: 900px) { .intro .intro-inner { grid-column: 1 / -1; margin-top: 0; } }

/* ---- v15: the contact band's foot print -----------------------------------
   The last crop of the negative, full width, knocked back to 16% so the form
   above it stays the loudest thing in the band. It is what stops the page
   ending on a rectangle of flat beige. */
.contact-foot {
  position: absolute; left: 0; right: 0; bottom: 0; height: clamp(80px, 13vh, 168px);
  z-index: 0; pointer-events: none; opacity: .16;
  background-image: url("assets/crowd/crowd-halftone.webp");
  background-size: cover; background-position: 50% 62%; background-repeat: no-repeat;
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 62%, #000 100%);
          mask-image: linear-gradient(to bottom, transparent 0%, #000 62%, #000 100%);
}
@supports not ((-webkit-mask-image: linear-gradient(#000, #000)) or (mask-image: linear-gradient(#000, #000))) {
  .contact-foot { opacity: .10; }
}
.contact .grid { position: relative; z-index: 1; }

/* ---- v18: the services band is three rows, not three floating cards -------
   Each row spans the full grid and splits it 5/7 (or 7/5 on the flipped one),
   so the text column and the object column are deliberately unequal and the
   side alternates down the band. The numeral hangs in the row's own margin.
   Everything belonging to one service — heading, sentence, detail list and the
   button the script injects after it — lives inside .service__text, so none of
   it can be auto-placed away from its own card. */
.services .grid { row-gap: clamp(32px, 4.6vh, 62px); }
.services .service {
  position: relative; grid-column: 1 / -1; margin-bottom: 0;
  display: grid; grid-template-columns: subgrid;
  align-items: start;
}
@supports not (grid-template-columns: subgrid) {
  .services .service { grid-template-columns: repeat(12, minmax(0, 1fr)); gap: var(--gap); }
}
.services .service__text { grid-column: 1 / 6; }
.services .service .service-media { grid-column: 7 / 13; margin-top: 0; }
/* the middle row flips, so the band has a rhythm instead of a left rail */
/* v18i: even rows flip, not just the second one — there are four services now
   and the alternation has to keep going or card 04 lands on the same side as 03 */
.services .service:nth-of-type(even) .service__text { grid-column: 8 / 13; }
.services .service:nth-of-type(even) .service-media { grid-column: 1 / 7; grid-row: 1; }
/* the object column is the taller one, so let the text sit optically centred
   against it rather than pinned to the top of a much longer neighbour */
@media (min-width: 900px) {
  .services .service__text { align-self: center; }
}
@media (max-width: 899px) {
  .services .service,
  .services .service:nth-of-type(even) { display: block; }
  .services .service__text,
  .services .service:nth-of-type(even) .service__text,
  .services .service .service-media,
  .services .service:nth-of-type(even) .service-media { grid-column: 1 / -1; }
  .services .service .service-media { margin-top: 18px; }
  .services .service + .service { margin-top: clamp(40px, 6vh, 64px); }
}
.services .service--link { display: block; color: inherit; text-decoration: none; }
/* ---- v15: the margin numeral ----------------------------------------------
   Each service gets its number set large in the margin, hanging outside the
   text block. It is what a numbered sheet looks like when a person set it, and
   it is the reason the three services can sit on overlapping, unequal spans
   without the band reading as chaos: the numerals carry the order.
   aria-hidden by construction (generated content), and it never carries
   information the copy does not already give. */
/* v16: the ghost numeral sat at top:-0.42em and left:-0.06em, i.e. directly
   behind the h3 — measured as a real overlap at both 390 and 1440. A watermark
   behind a heading is the kind of decoration that reads as trying too hard, and
   it made the heading harder to read for nothing. It now sits in the margin
   above the heading, at a size that labels rather than competes. */
.services .service > .svc-num {
  position: static; display: block; z-index: 0;
  font-family: var(--f-mono); font-weight: 500;
  font-size: var(--t-meta); line-height: 1;
  color: var(--accent-ink); opacity: 1;
  letter-spacing: .12em; margin-bottom: 8px;
  pointer-events: none; user-select: none;
}
.services .service > .service--link,
.services .service > .service-detail { position: relative; z-index: 1; }

/* v14: the detail list is what the dropdown reveals. It was not on the page at
   all before — the services said one sentence each and the section read as
   three headings floating in a lot of paper. Concrete, checkable items only. */
.service-detail { margin-top: 4px; }
.service-detail__item {
  position: relative; padding-left: 18px; margin-top: 7px;
  font-size: var(--t-body); line-height: 1.5; color: var(--fg-body); max-width: 40ch;
}
.service-detail__item::before {
  content: ""; position: absolute; left: 0; top: .62em;
  width: 7px; height: 1px; background: var(--accent);
}
/* v18: --t-h3 caps at 15px, which put the service titles BELOW the 14px body
   text beside them in visual weight — a hierarchy inversion, and most of why
   these cards read as weak next to their own pictures. The band's headings get
   their own scale. */
.services .service h3 {
  margin-bottom: 12px;
  font-size: clamp(1.125rem, 0.86rem + 1.16vw, 1.75rem);
  line-height: 1.08; letter-spacing: -0.015em;
}
/* a hairline over each row, so three services read as a numbered sheet rather
   than three cards someone dropped on the page */
.services .service + .service { border-top: 1px solid var(--rule); padding-top: clamp(24px, 3.6vh, 46px); }
.services .service .body-text { color: var(--fg-body); }
.services .service .service-media { margin-top: 16px; }
/* v14: 4/5 towers held mostly void; 5/4 shows the object instead of the box.
   The plate is dark on both themes because these are lit objects — the camera
   on a cream field read as a washed-out swatch, and the social tile read as an
   empty rectangle. Same material as the work rail's studio plate. */
/* v14. Three bugs in one place: (a) these were written as DESCENDANT selectors
   (`.service-media .frame-box`) but service-media, frame-box and the plate class
   are all on the SAME element, so none of them ever matched; (b) --ar was set in
   an inline style attribute, which outranks any stylesheet rule — the same trap
   GOAL.md documents for grid-column; (c) 4/5 towers meant each tile was mostly
   empty backdrop. --ar now lives here, and the selectors are compound. */
.services .service-media { --ar: 5/4; }
/* the camera plates are shot on a pale field, so this tile is light on purpose —
   but at 1/7 of the grid it rendered 660x840 of mostly backdrop */
/* v18: capped at 360px it was a postage stamp in a six-column track, with the
   rest of the track empty — one of the "empty space in places". The object
   column is now the object's, and 4/3 is the render's own frame. */
.services .service-media.object-3d { --ar: 648/417; max-width: none; }
/* v18d, owner: the screenshot shows as a full 16:9 rather than a crop of one.
   The plate's own ratio therefore has to be 16:9 PLUS the browser chrome above
   it, otherwise object-fit:cover eats the top and bottom of the capture — which
   is what it was doing at 1400/1000. Chrome is 14px padding + 8px dots + 10px
   gap + the address bar; --chrome carries it so the sum is stated once. */
.browser-plate { --chrome: 56px; }
.web-hero .hero-browser.browser-plate {
  aspect-ratio: auto; --ar: auto; height: auto;
}
/* A capture is 16:9 or it is a crop of one. `.services .service-media` sets 5/4
   one selector heavier than `.shot-frame` can, so the compound has to be spelled
   out here or every screenshot in a service card silently loses its top and
   bottom to object-fit. */
.services .service-media.shot-frame { --ar: 16/9; }
/* the social tile had no object in it at all: an empty black grid the size of a
   door. Real material instead — the same halftone plate as the hero, cropped to
   a portrait frame and darkened so the h3 above it still leads. */
.services .service-media.object-plate {
  --ar: 5/4; max-width: none;
  background:
    linear-gradient(180deg, rgba(23,19,14,.30) 0%, rgba(23,19,14,.80) 100%),
    url("assets/crowd/crowd-halftone.webp");
  background-size: auto, cover;
  background-position: 0 0, 48% 44%;
  background-repeat: no-repeat;
}
.services .service-media.tilt { transform: none; transition: transform var(--dur-ui) var(--spring); }
.service--link:hover .service-media.tilt, .service--link:focus-within .service-media.tilt { transform: scale(1.015); }
.service--link h3 { transition: transform var(--dur-ui) var(--spring); }
.service--link:hover h3, .service--link:focus-within h3 { transform: translateX(8px); }
/* v15: the closer hangs off the right-hand edge of the band instead of
   restarting at column 1 under a left-aligned stack — it is the sentence that
   closes the sheet, so it sits where the eye leaves. */
/* v18: the closer is the sentence that ends the sheet. It used to hang off the
   right at 6/13, under the third card's OBJECT rather than under any text —
   "the text under the last item is not really under it correctly". It sits in
   the same column as the text it closes, with the rule above it running the
   full measure. */
.services .closer { grid-column: 1 / 9; margin-top: 12px; color: var(--fg-muted); }
@media (min-width: 900px) {
  .services .closer {
    grid-column: 1 / 8; margin-top: clamp(24px, 3.6vh, 48px);
    /* without this the closer stretches to the height of the service beside it
       — a 47px paragraph rendering as a 638px box, measured */
    align-self: start;
  }
}
@media (max-width: 899px) { .services .closer { grid-column: 1 / -1; } }

.facts { position: relative; }
/* v18d, owner: the heading runs horizontal. Turning type on its side is a
   move that has to earn itself, and here it was costing a column of the grid
   and a 90-degree neck-turn to read three words. */
.facts h2 { grid-column: 1 / 13; margin-bottom: clamp(18px, 2.6vh, 34px); }
.facts-rows { grid-column: 1 / 13; }
.facts-row {
  display: grid; grid-template-columns: 34px 1fr; align-items: center; gap: 14px;
  min-height: 46px; border-bottom: 1px solid var(--edge-pitch); position: relative;
}
.facts-row .idx { font-family: var(--f-mono); color: var(--ash); font-size: var(--t-meta); }
.facts-row .txt { font-family: var(--f-display); font-weight: 600; font-size: var(--t-h3); color: var(--chalk); }
.facts-row .rule {
  position: absolute; left: 0; right: 0; bottom: -1px; height: 1px; background: var(--on-air);
  transform-origin: left; transform: scaleX(0); transition: transform 600ms var(--ease-io);
}
.facts-row.in-view .rule { transform: scaleX(1); }
/* nine identical rows is a table. Three of them are taller, and the whole
   list steps in 60ms at a time rather than arriving as a block. */
.facts-row:nth-child(3n+1) { min-height: 84px; }
.facts-row:nth-child(2) .rule { transition-delay: 60ms; }
.facts-row:nth-child(3) .rule { transition-delay: 120ms; }
.facts-row:nth-child(4) .rule { transition-delay: 180ms; }
.facts-row:nth-child(5) .rule { transition-delay: 240ms; }
.facts-row:nth-child(6) .rule { transition-delay: 300ms; }
.facts-row:nth-child(7) .rule { transition-delay: 360ms; }
.facts-row:nth-child(8) .rule { transition-delay: 420ms; }
.facts-row:nth-child(9) .rule { transition-delay: 480ms; }
@media (max-width: 640px) {
  .facts-row, .facts-row:nth-child(3n+1) { min-height: 58px; grid-template-columns: 32px 1fr 48px; }
}

/* ---- v15: "zakaj mi" stops being three of a thing, evenly ------------------
   It was 1/5 · 5/9 · 9/13 — three equal columns, the exact card-grid tell the
   doctrine names, with the h2 alone on its own row leaving 8 columns of nothing
   (measured 85px of void at 1440). It is now 2/6/4: a label, one loud reason,
   and two quieter ones stacked. The 26-years fact is the one that carries, so
   it is the one that is set large. */
.why { position: relative; }
.why-h2 { margin-bottom: clamp(22px, 3vh, 40px); }
.why-item {
  position: relative; padding-top: 16px; border-top: 1px solid var(--rule);
  display: flex; flex-direction: column; gap: 12px;
}
@media (min-width: 900px) {
  .why-h2 { align-self: start; margin-bottom: 0; }
  .why-item--lead p {
    font-size: var(--t-h2); line-height: 1.14; max-width: 16ch;
    letter-spacing: -0.02em;
  }
  .why-item--stack { margin-bottom: clamp(18px, 2.4vh, 30px); }
  .why-item--stack:last-of-type { margin-bottom: 0; }
}
/* v15b: out of flow, so its parallax travel can never push into the sentence
   below it, and composed from a custom property instead of an inline transform. */
.why-item { padding-top: 34px; }
.why-item .why-num {
  position: absolute; top: 14px; left: 0;
  font-family: var(--f-mono); font-size: var(--t-meta); letter-spacing: .1em;
  color: var(--accent-ink); line-height: 1;
  transform: translateY(var(--why-y, 0px));
  will-change: transform;
}
@media (prefers-reduced-motion: reduce) {
  .why-item .why-num { transform: none; will-change: auto; }
}
.why-item p { position: relative; z-index: 1; font-size: var(--t-h3); font-family: var(--f-display); font-weight: 600; max-width: 24ch; line-height: 1.3; }
@media (max-width: 900px) {
  /* three columns of reasons become three stacked reasons — without this the
     gc-* spans hold and each one gets a third of a 360px screen */
  .why-h2, .why-item { grid-column: 1 / -1; }
  .why-item { margin-bottom: 22px; }
}

.contact .contact-grid { align-items: start; }
.contact h2 { grid-column: 1 / 8; }
.contact .body-text { grid-column: 1 / 6; margin-top: 16px; }
.contact .btn { margin-top: 22px; }
.contact .contact-panel { grid-column: 7 / 13; }
.contact address { font-family: var(--f-mono); font-style: normal; font-size: var(--t-meta); margin-top: 20px; line-height: 1.9; }
.contact address a { text-decoration: none; }
@media (max-width: 900px) {
  .contact h2, .contact .body-text, .contact .contact-panel { grid-column: 1 / -1; }
}

/* footer */
.site-footer { position: relative; overflow: hidden; }
/* Two blocks, one baseline. It used to be four things at four different
   heights — the tagline at 35, contact at 0, follow at 22, and the language
   box and sound toggle stranded 200px below both. The statement holds the left,
   everything you can act on gathers on the right, and the tops line up. */
.site-footer .grid { position: relative; z-index: 2; align-items: start; }
.site-footer .tagline { grid-column: 1 / 6; grid-row: 1; font-size: var(--t-h2); margin: 0; }
/* a cluster, not a spread. at 7/10 and 10/13 the pair started in the middle of
   the sheet and read as three columns rather than two blocks; the right-hand
   group now begins where the left one clearly ends. */
.site-footer .contact-col { grid-column: 8 / 11; grid-row: 1; }
.site-footer .follow-col { grid-column: 11 / 13; grid-row: 1; }
.site-footer .foot-col { margin: 0; }
/* the language picker and the sound toggle belong with the things you press,
   so they sit under the two columns and centre on the same right-hand block */
.site-footer .gc-9-13 {
  grid-column: 8 / 13; grid-row: 2;
  display: flex; align-items: center; justify-content: flex-start; gap: 18px;
  margin-top: 30px;
}
.site-footer .foot-col h3.mono { color: var(--ash); margin-bottom: 12px; }
.site-footer .foot-col a { display: block; font-family: var(--f-text); text-decoration: none; margin-bottom: 6px; }
.site-footer .rights { grid-column: 1 / 6; grid-row: 2; margin-top: 30px; color: var(--ash); }
.site-footer .lang-fader { margin-top: 20px; }
/* The record that used to sit here span forever and said nothing — decoration
   on a loop, which is the definition of filler. A tuning scale is the same
   trade drawn instead of spun: 87.5 to 108 with real tick spacing, and a needle
   that answers the page rather than repeating. */
/* It was 1100px — 69% of the footer's width — bled 60px past the right trim and
   40px below it, and every text block down there sat on top of it: the tagline,
   both columns, the rights line and the language picker. A piece of studio
   equipment does not get to be wallpaper behind the address.
   It has its own strip now: half the size, centred on the footer's axis, under
   the content rather than behind it. The footer's padding was opened to make
   the room rather than the dial stealing it. */
/* room for the dial strip under the content, so the two never share space */
/* room for the dial strip beneath the two rows. 104px was enough while the
   language box sat 200px down the page; with the composition tightened the
   second row now ends at 182 and the dial was starting at 158. */
.site-footer { padding-bottom: clamp(154px, 14vh, 184px); }
@media (max-width: 900px) { .site-footer { padding-bottom: clamp(112px, 14vh, 140px); } }

.footer-dial {
  position: absolute; left: 50%; transform: translateX(-50%);
  bottom: 4px; width: min(430px, 58vw);
  max-width: none; opacity: .30; z-index: 0; pointer-events: none; color: var(--ash);
}
.footer-dial svg { display: block; width: 100%; height: auto; overflow: visible; }
.dial-needle { transform: translateX(var(--dial-x, 300px)); will-change: transform; }
@media (prefers-reduced-motion: reduce) { .dial-needle { transform: translateX(520px); } }
@media (max-width: 900px) { .footer-dial { bottom: 2px; width: min(300px, 74vw); opacity: .26; } }
@media (max-width: 900px) {
  /* the desktop composition pins these to explicit rows so the two blocks share
     a baseline. In one column that same pinning stacks all three into row 1 on
     top of each other and both second-row items into row 2 — measured 4
     overlapping pairs at every width from 320 to 430. Source order, on mobile. */
  .site-footer .tagline, .site-footer .contact-col, .site-footer .follow-col,
  .site-footer .rights, .site-footer .gc-9-13 {
    grid-column: 1 / -1; grid-row: auto;
  }
  .site-footer .tagline, .site-footer .contact-col, .site-footer .follow-col { margin-bottom: 28px; }
  .site-footer .rights { margin-top: 8px; }
  .site-footer .gc-9-13 { justify-content: flex-start; margin-top: 22px; }
  /* the footer's lang-fader + sound-toggle container kept its desktop span
     9/13 through this breakpoint and shoved the fader 24px past the edge at
     320px — same inline-span trap GOAL.md warns about, caught by the rect
     walk. Full row, content left. */
  .site-footer .gc-9-13 { grid-column: 1 / -1; }
}

/* ==========================================================================
   WEBSITES
   ========================================================================== */
/* v15: the hero was h1 1/9 with lead AND body both stacked in 1/6 and the
   object parked in the right margin — 5 columns of type running tall beside a
   decoration, which is what "thin hero" means. Headline takes 9 columns; lead
   and body run side by side at 6/5; the object drops below them and moves
   left, so it is the thing the band is about. */
.web-hero { position: relative; }
/* h1's global measure is 19ch, which capped this headline at 703px @1440 and
   broke a 78-character sentence over 4 tight lines while 283px of its 9-column
   track sat unused. 24ch = 888px @1440 and 1104px @1920, both inside the
   track, and the sentence lands in 3 fuller lines. Only above 1100px, where
   the track is actually that wide. */
/* v18: the two sentences used to run side by side at 1/7 and 7/12 with the
   object below them, which left the entire lower-left quadrant of the band
   empty once the object moved right. They stack in the left column instead and
   the object sits beside them — one text mass, one object, nothing orphaned. */
.web-hero h1 { grid-column: 1 / 10; }
@media (min-width: 1100px) { .web-hero h1 { max-width: 24ch; } }
/* v18b: the lead is set at heading scale. With the object on the right at hero
   size, two 14px paragraphs left ~400px of the lower-left quadrant empty — the
   band had a big picture and no counterweight. The lead is the sentence that
   answers the headline, so it is the counterweight, and the hierarchy inside
   the column stops being flat. */
.web-hero .lead {
  grid-column: 1 / 6; margin-top: clamp(20px, 3vh, 38px);
  font-size: var(--t-h2); line-height: 1.2; max-width: 22ch;
  color: var(--fg); letter-spacing: -0.012em;
  padding-left: 22px; position: relative;
}
.web-hero .lead::before {
  content: ""; position: absolute; left: 0; top: .16em; bottom: .16em; width: 3px;
  background: var(--accent);
}
.web-hero .body-text { grid-column: 1 / 6; margin-top: clamp(16px, 2.4vh, 28px); }
/* THE ONE SLANT ON THE SITE. v18, owner's call: it moves RIGHT. It used to
   start at column 1 and pull a further inset left of that, so it sat on the
   trim with the whole right half of the band empty beside it. On columns 5-13
   it lands under the body copy, where the eye already is, and the band has a
   left-heavy text mass balanced by a right-heavy object — which is the point
   of an asymmetric hero. The roll stays; nothing bleeds. */
.web-hero .hero-browser {
  justify-self: end; align-self: start; width: 100%;
  /* v18b, owner: "the item that used to be big and looked good is now small".
     It is the hero's object and it should read at hero size — the cap only
     stops it running the full seven columns and dragging the band past a
     screen. It keeps its size and gains the right-hand position. */
  max-width: clamp(300px, 42vw, 560px);
  --ar: 16/10; margin-top: clamp(18px, 2.6vh, 34px);
  transform: rotate(1.5deg);
  transition: transform var(--dur-ui) var(--spring);
}
.web-hero .hero-browser:hover { transform: rotate(0deg) scale(1.02); }
@media (max-width: 900px) {
  .web-hero h1, .web-hero .lead, .web-hero .body-text,
  .web-hero .hero-browser{ grid-column: 1 / -1; margin: 24px 0 0; }
  /* the slant is a desktop gesture; at 360px it just eats the right edge */
  .web-hero .hero-browser { transform: none; --ar: 16/10; }
  .web-hero .hero-browser:hover { transform: scale(1.01); }
}

/* v18 — MEASURED BUG, and it is the whole of "the laptop animation sucks".
   The stage declared columns but never declared rows. .offer-pin took 1/5 and
   .plate-wrap took 4/13; the two spans OVERLAP in the column axis, so grid
   auto-placement could not put them on the same row and pushed the plate onto
   row 2, and .stage-cap onto row 3. Row 2 was sized by its content, and the
   plate's only child is position:absolute — zero content height. So
   `height:100%` on .plate-wrap resolved against a collapsed row: the laptop
   rendered 925x95, a sliver pinned to the right edge with the section's grid
   texture printing through it, which is exactly what the owner photographed.
   Rows are explicit now, the two spans no longer overlap, and the plate gets
   the row that the stage's height actually belongs to. */
.laptop-stage {
  position: sticky; top: 0; height: 100svh; display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  grid-template-rows: minmax(0, 1fr) auto;
  gap: var(--gap); padding-inline: var(--inset-l) var(--inset-r);
  padding-block: clamp(64px, 11vh, 118px) clamp(18px, 3vh, 32px);
  align-items: center;
  /* `clip`, not `hidden` — the same reason as `.section`. `hidden` makes this a
     scroll container, and a scroll container is what the view() timelines on
     .laptop-seq and the offer list resolve against, so all eight of them were
     resolving against a box that never scrolls. This is the sticky stage the
     laptop lives in, which is why the laptop animation kept reading as a badly
     keyframed overlay: it was stepping, not scrubbing. */
  overflow: clip;
}
.laptop-stage .offer-pin { grid-column: 1 / 5; grid-row: 1; z-index: 2; align-self: center; }
.offer-pin h2 { margin-bottom: clamp(20px, 3.4vh, 38px); }
/* v18: seven ranged-left sentences in a 4-column track read as a paragraph
   someone forgot to write, and left the column visibly shorter than the object
   beside it — which is the "empty space in places" on this band. They are a
   spec sheet, so they are set as one: a counter, a hairline per row, and the
   row rules stepping in as each item arrives. The column now stands at roughly
   the object's height, so the pair reads as two blocks rather than a caption
   next to a photograph. */
.offer-pin ul { display: block; counter-reset: offer; }
.offer-pin li {
  counter-increment: offer;
  position: relative; padding: clamp(9px, 1.3vh, 15px) 0 clamp(9px, 1.3vh, 15px) 42px;
  color: var(--ash); line-height: 1.35;
  opacity: 0; transform: translateX(-14px);
  transition: opacity var(--dur-enter) var(--ease-out), transform var(--dur-enter) var(--ease-out), color var(--dur-enter) linear;
}
.offer-pin li::before {
  content: "0" counter(offer);
  position: absolute; left: 0; top: 50%; transform: translateY(-50%);
  font-family: var(--f-mono); font-size: var(--t-meta); letter-spacing: .1em;
  color: var(--ash);                      /* 7.16:1 on --pitch */
}
/* the rule belongs to the row above it, so the last item has no dangling line */
.offer-pin li + li::after {
  content: ""; position: absolute; left: 0; right: 0; top: 0; height: 1px;
  background: var(--edge);
  transform: scaleX(0); transform-origin: 0 50%;
  transition: transform 420ms var(--ease-out);
}
.offer-pin li.shown { opacity: 1; transform: translateX(0); color: var(--chalk); }
.offer-pin li.shown::before { color: var(--on-air-hot); }
.offer-pin li.shown + li::after,
.offer-pin li.shown::after { transform: scaleX(1); }
/* v15: the laptop bleeds off the right edge of the sheet instead of stopping
   inside the inset, and it uses the full height of the sticky stage now that
   the stage has real travel to scrub through. */
/* v16c: the plate filled 74% of its stage and sat in a 12-col grid starting at
   column 5, so ~26% of an 85svh sticky stage was empty and the void sweep found
   a 753px band of nothing on websites.html — the single largest void on the
   site. The laptop is the reason this section exists; it should fill it.
   Full height, starting one column earlier, and bleeding right. */
/* v18: no bleed off the right edge. The bleed is what made the laptop look
   thrown at the trim; the unit is the subject of this band and it sits inside
   it. place-items:center + the frame's own aspect-ratio on the child means the
   artwork is never letterboxed inside its own box, which is the precondition
   for welding anything onto the screen in percentages. */
.laptop-stage .plate-wrap {
  grid-column: 5 / 13; grid-row: 1; position: relative;
  height: 100%; min-height: 0; display: grid; place-items: center;
}
/* Fit-to-box without distortion and without JS. `width:100% + aspect-ratio`
   alone derives a height that can exceed the row and get clipped; `height:100%
   + max-width` clamps one axis and stretches the other. Deriving the width from
   the row height the stage actually gives it — which is known, because the
   stage height and its padding are both declared right here — satisfies both
   axes at once. The 40px is the caption row plus the grid gap. */
.laptop-stage .plate-wrap .laptop-seq {
  --fit-h: calc(100svh - clamp(64px, 11vh, 118px) - clamp(18px, 3vh, 32px) - 40px);
  width: min(100%, calc(var(--fit-h) * 683 / 624));
}
/* the running caption under the scene */
.laptop-stage .stage-cap {
  grid-column: 5 / 13; grid-row: 2; align-self: end; text-align: right;
  font-family: var(--f-mono); font-size: var(--t-stamp); letter-spacing: .14em;
  text-transform: uppercase; color: var(--ash-dim);       /* 3.32:1 — decorative, ≥24px rule N/A: aria-hidden */
}

/* v15: .offer-ledger is deleted. It was a whole 209px band holding a single
   row; its one item is the seventh <li> of the list beside the laptop and its
   closer opens the "already have a site" band. */

/* v18: explicit rows. Auto-placement was pushing the slab BELOW the sentence
   because their column spans overlap, so the band read as a heading and a
   paragraph alone at the top with a picture parked under them against the right
   trim — "text all the way up there and it makes it empty". The picture now
   starts on the same row as the heading and spans the empty row beneath, so the
   two columns start together and the band has no dead quadrant. */
.web-existing h2 { grid-column: 1 / 6; grid-row: 2; }
/* :not(.closer) — the closer carries .body-text too, so an unqualified
   `.web-existing .body-text` outranked `.web-existing__closer` and put both
   paragraphs on the same grid row, printed over each other. */
.web-existing .body-text:not(.closer) { grid-column: 1 / 6; grid-row: 3; margin-top: 20px; color: var(--ash); align-self: start; }
.web-existing__closer {
  grid-column: 1 / 9; grid-row: 1; margin-bottom: clamp(24px, 4vh, 52px);
  color: var(--ash); border-bottom: 1px solid var(--edge); padding-bottom: 18px;
}
.web-existing .slab-wrap { grid-column: 6 / 13; grid-row: 2 / 5; align-self: start; }
/* v15: the rotateY(-6deg) is gone. The owner asked for the slants to go and did
   not carve out a 3D exception; the slab gets its depth from a real drop
   shadow and a scale on approach instead of from a rotation. */
.web-existing .slab-wrap .frame-box {
  --ar: 2400/1600;
  box-shadow: -30px 30px 60px rgba(0,0,0,.4);
  transition: transform var(--dur-ui) var(--spring);
}
.web-existing .slab-wrap:hover .frame-box { transform: scale(1.012); }
/* v15: 7/5, not 50/50, and the second option sits lower — an even split reads
   as a decision nobody made */
/* The fork used to sit in its own full-width row UNDER the screenshot, which
   left ~510px of nothing in the left column beside a 570px picture and then
   started a fresh band below it — the "pretty empty below" the owner reported.
   It moves into that hole: the two routes stack in the text column and read
   beside the evidence they are talking about, and the section loses the dead
   row entirely. Full width again on narrow screens, where there is no column to
   sit beside. */
.web-existing .fork {
  grid-column: 1 / 6; grid-row: 4; align-self: start;
  display: flex; flex-direction: column; gap: 14px; margin-top: 30px;
}
.web-existing .fork > div { padding: 20px; background: var(--bg-hi); border-radius: 4px; position: relative; }
.web-existing .fork .fork__b { margin-top: 0; }
.fork__num {
  display: block; margin-bottom: 10px; color: var(--accent);
  font-size: var(--t-stamp); letter-spacing: .18em;
}
@media (max-width: 900px) {
  .web-existing h2, .web-existing .body-text, .web-existing .slab-wrap,
  .web-existing__closer, .web-existing .fork { grid-column: 1 / -1; grid-row: auto; margin-top: 24px; }
  .web-existing .fork { grid-template-columns: 1fr; }
  .web-existing .fork .fork__a, .web-existing .fork .fork__b { grid-column: 1 / -1; margin-top: 0; }
}

.examples-grid { display: grid; grid-template-columns: repeat(12, 1fr); gap: var(--gap); }
/* v14: bento — the frames get explicit heights instead of a uniform rhythm,
   so the band reads as a mounted contact sheet, not a card grid */
/* v16: four frames at four spans, two of them nudged down 80px and 40px. A set
   of examples of the same thing is a SET — mismatched empty frames do not read
   as editorial, they read as broken. Owner: "the video egsampels arent
   symetrical and the same shape or size". One span, one ratio, one baseline.
   The hand-set asymmetry belongs in the page composition, not inside a grid of
   like-for-like items. */
.examples-grid .example-frame { grid-column: span 6; margin-top: 0; }
@media (min-width: 1100px) { .examples-grid .example-frame { grid-column: span 4; } }
@media (max-width: 900px) {
  .examples-grid .example-frame { grid-column: 1 / -1 !important; margin-top: 0 !important; }
  .examples-grid { gap: 24px; }
}

/* ==========================================================================
   SOCIAL
   ========================================================================== */
.social-hero { position: relative; min-height: min(84svh, 675px); display: flex; flex-direction: column; justify-content: flex-start; }
/* v18. Two things made this band read as "a dark banner block over the people".
   1. The plate was 52vh pinned to the bottom, so the crowd was a horizontal
      strip with flat dark above it and a hard cut below it into the seam.
      It runs the full height of the hero now and fades out at both ends.
   2. --dot-crop-top throws away the top third of the negative, which is the
      unlit ceiling above the audience — see the note in site.js. Without it a
      short wide box fills its upper half with black no matter what the mask
      does, because there is nothing in that part of the photograph to draw. */
.social-hero .crowd-plate {
  position: absolute; inset: 0; z-index: 0;
  --dot-crop-top: 0.34;
}
.social-hero .crowd-plate::before { background-position: 50% 88%; }
.social-hero .crowd-plate {
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 26%, #000 82%, transparent 100%);
          mask-image: linear-gradient(to bottom, transparent 0%, #000 26%, #000 82%, transparent 100%);
}
/* v13: the social hero gets the home treatment — the same diagonal scrim and
   text-shadows, so text never floats 1:1 against the dots */
.social-hero::after {
  content: ""; position: absolute; inset: 0; z-index: 1; pointer-events: none;
  background: linear-gradient(100deg,
    rgba(10,8,6,.92) 0%, rgba(10,8,6,.74) 34%, rgba(10,8,6,.18) 62%, rgba(10,8,6,0) 84%);
}
/* v15b — MEASURED AA FAILURE, found by sampling rendered pixels rather than
   tokens. .social-hero .body-text sits at columns 7-12, which is exactly where
   the diagonal scrim above has already faded to zero, directly on the bright
   halftone dots. --ash on that backdrop measured 1.40:1 at 390px, 1.78:1 at
   1440 and 1.62:1 at 1920 — the text-shadows below were never going to carry
   it. Same title-card treatment as the home hero: the type gets a band, the
   photograph keeps its strength everywhere else. Composited, --ash over
   rgba(23,19,14,.88) on the worst dot is 6.02:1. */
   Banding the whole grid would have covered the top half of the hero, where
   there is no crowd at all (the plate is the bottom 52vh) — that would have
   been mush for no reason. Only the body-text overlaps the dots, so only the
   body-text gets a card, with an on-air rule down its left edge so it reads as
   a note laid on the print rather than a patch. */
.social-hero .body-text {
  position: relative; padding: 12px 16px 14px 18px;
}
/* v16h: same fix as the home hero. The card stays — the measured 1.40:1 that
   made it necessary is real — but the hard --on-air rule down its edge is what
   made it read as a patch stuck on the picture. A soft edge instead, and the
   fill eased so it has no visible boundary against the dots. */
.social-hero .body-text::before {
  content: ""; position: absolute; inset: -6px -10px -8px -12px; z-index: -1;
  border-radius: 2px;
  background: radial-gradient(120% 140% at 30% 50%,
    rgba(23, 19, 14, .90) 0%, rgba(23, 19, 14, .82) 55%, rgba(23, 19, 14, .34) 100%);
  pointer-events: none;
}
.social-hero h1 { text-shadow: none; }
.social-hero .lead { color: var(--chalk); text-shadow: none; }
.social-hero .body-text { text-shadow: none; }
.social-hero .hero-mono { justify-content: flex-start; grid-column: 1 / 4; }

.social-hero .grid { position: relative; z-index: 2; padding-top: clamp(120px, 18vh, 220px); }
.social-hero h1 { grid-column: 1 / 9; }
.social-hero .lead { grid-column: 1 / 6; margin-top: 24px; }
.social-hero .body-text { grid-column: 7 / 12; margin-top: 28px; }
@media (max-width: 900px) { .social-hero h1, .social-hero .lead, .social-hero .body-text, .social-hero .hero-mono { grid-column: 1 / -1; margin-top: 20px; } }

/* v18. The phone carried .object-plate, and `.object-plate > svg` is
   display:none — so the one illustration in this band was hidden inside an
   empty textured rectangle, and the band measured as a ~210px void with a grid
   pattern in it. The plate class is off the element in the markup; the drawing
   is the point. Columns rebalanced so the two lists and the object share the
   row instead of the object being pushed to a row of its own by an overlapping
   span. */
.social-offer .col-a { grid-column: 1 / 6; display: flex; flex-direction: column; gap: 20px; }
.social-offer .col-a li { font-family: var(--f-display); font-weight: 600; font-size: clamp(1rem, 0.8rem + 0.9vw, 1.5rem); line-height: 1.14; }
.social-offer .col-b { grid-column: 6 / 10; margin-top: 6px; display: flex; flex-direction: column; gap: 14px; }
/* The handset is 460px of object beside 180px of list, and the row took the
   taller of the two — which left ~280px of nothing under the copy. The object
   spans both rows now and the closer rides up beside it, so the band is as tall
   as the phone because the phone is in it, not because a row was padded out. */
.social-offer .col-a, .social-offer .col-b { grid-row: 1; }
.social-offer .phone-wrap { grid-row: 1 / 3; align-self: center; }
.social-offer .closer { grid-row: 2; align-self: start; }
.social-offer .phone-wrap {
  grid-column: 10 / 13; justify-self: center; align-self: start;
  line-height: 0; perspective: 1100px;
}
/* The render carries its own alpha, so the only shadow it needs is the one it
   casts on the page — no plate, no card, no box. drop-shadow follows the
   silhouette; box-shadow would draw a rectangle around a phone-shaped hole. */
.social-offer .phone-render {
  /* sized to the copy it stands beside, not to the column it sits in — at
     220px wide it ran 460px tall against 180px of list and the band had to
     pad the difference */
  display: block; width: min(19vw, 176px); height: auto;
  filter: drop-shadow(0 8px 12px rgba(23,19,14,.26));
  transform-origin: 50% 60%;
  will-change: transform;
}
/* MOTION 1 of 3 — the handset turns as it travels through the viewport.
   Driven by a view-progress timeline, so the browser runs it on the compositor
   off the scroller itself: no scroll listener, no rAF, nothing on the main
   thread. Browsers without scroll-driven animations simply get the static
   render at its resting angle, which is the frame it was rendered at. */
@keyframes phone-turn {
  from { transform: rotateY(-13deg) rotateZ(-1.2deg) translateY(14px); }
  to   { transform: rotateY(11deg)  rotateZ(1.4deg)  translateY(-16px); }
}
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .social-offer .phone-3d {
      animation: phone-turn linear both;
      animation-timeline: view();
      animation-range: entry 0% exit 100%;
    }
  }
}
/* Safari on iOS and older Android have no view timeline, and there the handset
   just sat still. initViewMotionFallback() feeds --vp (0..1) for those, and the
   turn is spelled out here from the same numbers as the keyframes above, so
   there is one motion with two drivers rather than two motions. */
@supports not (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .social-offer .phone-3d {
      transform:
        rotateY(calc(-13deg + var(--vp, .5) * 24deg))
        rotateZ(calc(-1.2deg + var(--vp, .5) * 2.6deg))
        translateY(calc(14px - var(--vp, .5) * 30px));
    }
  }
}
@media (max-width: 900px) {
  .social-offer .col-a, .social-offer .col-b, .social-offer .phone-wrap { grid-column: 1 / -1; margin: 20px 0 0; }
  /* the desktop composition put the object across both rows so it could stand
     beside the copy. In one column that same rule parks it ON the copy — every
     item lands in row 1 together. Stack them in source order instead. */
  .social-offer .col-a, .social-offer .col-b, .social-offer .closer, .social-offer .phone-wrap {
    grid-row: auto; align-self: auto;
  }
  .social-offer .phone-wrap { justify-self: center; margin-top: 34px; }
  .social-offer .phone-render { width: min(52vw, 240px); }
  .social-offer .phone-wrap { overflow: visible; }
}

.social-feel { position: relative; }
/* v13: the closer's margin left the inline style attribute — one less inline */
.social-offer .closer { margin-top: 28px; }
/* v18: explicit rows so the text column and the fan share the band instead of
   the fan setting a 480px row height with 130px of type floating at the top of
   it. The body was also indented to column 2 for no reason the rest of the page
   repeats — it ranges left with everything else now. */
.social-feel h2 { grid-column: 1 / 7; grid-row: 1; }
.social-feel .body-text { grid-column: 1 / 7; grid-row: 2; margin-top: 20px; color: var(--ash); align-self: start; }
/* row 3 exists only to absorb the fan's leftover height. Without it the slack
   lands in row 2 and opens a gap between the heading and the sentence under it. */
.social-feel .grid { grid-template-rows: auto auto 1fr; }
/* v18: three 9:16 boxes filled with a colour one step off the section's own
   background, stacked almost on top of each other — on a dark band they were
   invisible, so this half of the row measured as a void and the owner
   photographed it as one. They are STORY FRAMES: three vertical crops of the
   same negative the rest of the page prints, actually fanned, each with a
   hairline and its own crop so they read as three different posts. */
/* the fan sizes off its own width: the plates are a percentage of it and 9:16
   tall, so a fixed pixel height either clipped them or left a gap under them.
   100/86 is the ratio that holds a 46%-wide 9:16 plate plus the fan's tilt. */
/* The container was 0.80 of its width tall while each plate computed to 0.818
   before rotation, so every frame overflowed its own box and the section cut
   the tops and bottoms off — which is why the three read as one torn strip.
   Sized to what the plates actually need: 42% wide at 9:16 is 0.747, plus the
   5 degrees of rotation and the 9px offsets. It also starts a column earlier,
   because the copy beside it is three lines and the band was half empty. */
.social-feel .fan { grid-column: 7 / 13; grid-row: 1 / 4; position: relative; aspect-ratio: 100 / 86; margin-top: 0; align-self: center; }
.social-feel .fan .frame-plate-box {
  position: absolute; top: 3%; width: 40%; aspect-ratio: 9/16;
  border-radius: 4px; overflow: hidden;
  border: 1px solid var(--edge);
  box-shadow: 0 22px 44px rgba(0,0,0,.5);
  background-image: image-set(
    url("assets/crowd/crowd-halftone-invert.webp") type("image/webp"),
    url("assets/crowd/crowd-halftone-invert.png")  type("image/png"));
  /* scale by HEIGHT: the source is a 3.48:1 panorama and these frames are 9:16,
     so sizing by width letterboxes every one of them. auto/116% fills and
     leaves a little room for each frame to take a different crop. */
  background-size: auto 104%;
  background-repeat: no-repeat;
  transition: transform var(--dur-ui) var(--spring);
}
/* v18h, owner: "theres three so thre pic". These were three crops of ONE file
   under one grade, which is one picture shown three times. Three separate
   plates now, cut from regions of the negative far enough apart to be
   different scenes, and the third is printed as a negative so the set reads as
   three posts rather than as a contact strip of one frame. */
.social-feel .fan .fp1 { left: 0;   background-image: url("assets/crowd/story-a.webp"); transform: rotate(-4.5deg) translateY(6px); z-index: 1; }
.social-feel .fan .fp2 { left: 30%; background-image: url("assets/crowd/story-b.webp"); transform: rotate(0.5deg);                  z-index: 2; }
.social-feel .fan .fp3 { left: 58%; background-image: url("assets/crowd/story-c.webp"); transform: rotate(5deg) translateY(9px);    z-index: 3; }
.social-feel .fan:hover .fp1 { transform: rotate(-7deg) translateY(4px); }
.social-feel .fan:hover .fp3 { transform: rotate(7.5deg) translateY(8px); }
@media (prefers-reduced-motion: reduce) { .social-feel .fan .frame-plate-box { transition: none; } }
@media (max-width: 900px) {
  .social-feel h2, .social-feel .body-text, .social-feel .fan { grid-column: 1 / -1; grid-row: auto; margin-top: 20px; }
  .social-feel .fan { aspect-ratio: 100 / 62; }
}

/* v18: this band was set at 6/13 and 8/13, both right-aligned with margin-left:
   auto — a heading and a paragraph pushed into the right trim with two thirds
   of the row empty beside them. It is the "screenshot/item all the way on the
   right and text all the way up there" complaint in text form. Ranged left with
   the rest of the page; the fork below it already provides the asymmetry. */
.social-existing h2 { grid-column: 1 / 8; text-align: left; margin-left: 0; }
.social-existing .body-text { grid-column: 1 / 7; margin-top: 20px; margin-left: 0; }
.social-existing .fork { grid-column: 1 / 13; display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--gap); margin-top: 36px; }
.social-existing .fork > div { padding: 20px; background: var(--bg-hi); border-radius: 4px; }
/* v13: the inline padding-top:0 that welded feel to existing is a class now —
   the .bar hairline between the two is the intentional seam */
.social-existing.flush-top { padding-top: 0; }
@media (max-width: 900px) {
  .social-existing h2, .social-existing .body-text { grid-column: 1 / -1; }
  .social-existing .fork { grid-template-columns: 1fr; }
}

/* v14: the vertical slabs met the scrollbar with no air, so the rail read as
   clipped rather than scrollable. 22px of padding under the frames gives the
   custom thumb its own lane. */
.examples-rail {
  display: flex; gap: 26px; overflow-x: auto;
  padding-inline: var(--inset); padding-right: 20vw; padding-bottom: 22px;
  scroll-snap-type: x proximity; overscroll-behavior-x: contain;
}
.examples-rail .example-frame { flex: 0 0 auto; width: min(220px, 50vw); scroll-snap-align: start; }

/* ---- contact confirmation dialog (v14) -------------------------------------
   The conversion point used to hand off to mailto: and say nothing. This is the
   answer: what happened, what to do next, and the address still on screen in
   case the hand-off silently failed. Scrim + panel, focus-trapped in JS.
   The panel is on --carbon with --chalk text (12.4:1) — red is the lamp only,
   never the label, and the panel never sits on beige. */
.confirm { position: fixed; inset: 0; z-index: 400; display: grid; place-items: center; padding: 20px; }
.confirm[hidden] { display: none; }
.confirm__scrim {
  position: absolute; inset: 0; background: rgba(10, 8, 6, .74);
  opacity: 0; transition: opacity 260ms var(--ease-out);
}
.confirm.is-open .confirm__scrim { opacity: 1; }
.confirm__panel {
  position: relative; width: min(520px, 100%);
  background: var(--carbon); color: var(--chalk);
  border: 1px solid var(--ash-dim); border-radius: 4px;
  padding: clamp(22px, 3.4vw, 36px);
  box-shadow: 0 28px 70px rgba(0,0,0,.6);
  opacity: 0; transform: translateY(12px) scale(.985);
  transition: opacity 300ms var(--ease-out), transform 420ms var(--spring);
}
.confirm.is-open .confirm__panel { opacity: 1; transform: none; }
.confirm__lamp {
  display: block; width: 34px; height: 5px; border-radius: 3px;
  background: var(--on-air); box-shadow: 0 0 14px var(--on-air); margin-bottom: 18px;
}
.confirm__title {
  font-family: var(--f-display); font-weight: 800; font-size: var(--t-h2);
  letter-spacing: -0.016em; line-height: 1.08; color: var(--chalk); max-width: 18ch;
}
.confirm__body { margin-top: 12px; font-size: var(--t-body); line-height: 1.6; color: var(--ash); max-width: 46ch; }
.confirm__fallback { margin-top: 16px; color: var(--ash); font-size: var(--t-meta); }
.confirm__mail { color: var(--chalk); text-underline-offset: 3px; }
.confirm__actions { display: flex; flex-wrap: wrap; align-items: center; gap: 14px 20px; margin-top: 24px; }
.confirm__copy {
  background: none; border: 1px solid var(--ash-dim); color: var(--ash);
  border-radius: 999px; padding: 10px 18px; min-height: 44px; cursor: pointer;
  font-family: var(--f-mono); font-size: var(--t-meta); text-transform: uppercase; letter-spacing: .08em;
  transition: color var(--dur-ui) var(--ease-out), border-color var(--dur-ui) var(--ease-out), transform var(--dur-tap) var(--ease-out);
}
.confirm__copy:hover { color: var(--chalk); border-color: var(--chalk); }
.confirm__copy:active { transform: scale(.97); }
.confirm__copy.is-done { color: var(--on-air-hot); border-color: var(--on-air-hot); }
@media (prefers-reduced-motion: reduce) {
  .confirm__scrim, .confirm__panel { transition: none; opacity: 1; transform: none; }
}

/* ---- progressive disclosure (v14) ------------------------------------------
   Owner: long small print under a big heading becomes a dropdown after a
   smaller amount. Collapsed height is set from the real line-height in JS, so
   it is always a whole number of lines and never cuts a line in half. The
   fade at the bottom edge says "there is more" without a label doing it.
   max-height is the transitioned property here rather than a transform: this
   is a one-shot on click, not a scroll-driven effect, and it is the only
   approach that works from Chrome 26 through today without a fallback path. */
.disclosure__panel {
  max-height: var(--collapsed, 4.5em); overflow: hidden; position: relative;
  transition: max-height 420ms var(--ease-io);
}
.disclosure__panel::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 2.1em;
  background: linear-gradient(to bottom, transparent, var(--bg) 92%);
  opacity: 1; transition: opacity 260ms var(--ease-out); pointer-events: none;
}
.disclosure__panel.is-open { max-height: var(--expanded, 240em); }
.disclosure__panel.is-open::after { opacity: 0; }
/* a panel that starts fully closed has no visible last line to fade, so the
   gradient would just be a smudge sitting on the paragraph above it */
.disclosure__panel--shut:not(.is-open)::after { opacity: 0; }
.disclosure__toggle {
  display: inline-flex; align-items: center; gap: 9px;
  margin-top: 12px; min-height: 44px; padding: 8px 2px;
  background: none; border: 0; cursor: pointer; color: var(--fg-small-muted);
  font-family: var(--f-mono); font-size: var(--t-meta);
  text-transform: uppercase; letter-spacing: .09em;
  transition: color var(--dur-ui) var(--ease-out);
}
.disclosure__toggle:hover { color: var(--fg); }
.disclosure__chev {
  width: 9px; height: 9px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor;
  transform: rotate(45deg) translate(-2px, -2px);
  transition: transform 300ms var(--spring);
}
.disclosure__toggle[aria-expanded="true"] .disclosure__chev { transform: rotate(-135deg) translate(-2px, -2px); }
@media (prefers-reduced-motion: reduce) {
  .disclosure__panel, .disclosure__chev { transition: none; }
}

/* ---- v16 kinetics ----------------------------------------------------------
   --vel (-1..1) is scroll velocity, written by initKinetics(). Display type
   leans into the scroll and settles — the page reads as having mass rather
   than as a document that jumps. The numbers are deliberately small: 1.4deg of
   skew and 1.5% of stretch is felt, not seen. Anything larger reads as a toy.

   --mx/--my are the magnetic offset on controls, capped at 7px in JS.

   Both are pure transform, both compose with whatever else the element does,
   and both are absent under reduced motion because initKinetics() returns
   before attaching a single listener. */
:root { --vel: 0; }
h1, .stamp b {
  transform: skewY(calc(var(--vel) * -1.4deg)) scaleY(calc(1 + var(--vel) * 0.015));
  transform-origin: left center;
  will-change: transform;
}
/* the headline reveal owns .two-tone's transform, so the lean goes on an inner
   layer there instead of fighting it */
h1.two-tone { transform: none; }
h1.two-tone > * { display: block; transform: skewY(calc(var(--vel) * -1.4deg)); transform-origin: left center; }

.btn, .disclosure__toggle {
  transform: translate3d(var(--mx, 0px), var(--my, 0px), 0);
  transition: transform 420ms var(--spring);
}
/* while the pointer is inside the radius the JS drives it every frame, so the
   transition would fight it — it only does the release */
.btn:hover, .disclosure__toggle:hover { transition: none; }

@supports not (transform: skewY(1deg)) {
  h1, .stamp b, h1.two-tone > * { transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  h1, .stamp b, h1.two-tone > *, .btn, .disclosure__toggle { transform: none; }
}

/* ---- v16b: cap the decorative plates -------------------------------------
   He has asked for a zoomed-out site four times. Type and padding took the full
   25%, but document height only moved 10-15% because the PLATES set the height:
   .web-existing .slab-wrap was 544px, taller than the copy it illustrates.
   An image that outgrows its own paragraph
   is not scale, it is a poster.

   A plate now caps at 26rem and never exceeds the column it sits beside. On a
   phone the cap drops again — a 400px illustration between two paragraphs is
   most of a phone screen. */
/* the fan is excluded: it is sized by aspect-ratio now, and a max-height on it
   clips the story frames rather than cropping them */
/* the browser plate and the screenshot frames are excluded: their height is
   derived from a real aspect ratio, so a max-height on them does not crop the
   box, it crops the PICTURE. Everything else still gets the cap. */
.services .service-media:not(.browser-plate):not(.shot-frame):not(.phone-object) {
  max-height: 19rem;
}
@media (max-width: 900px) {
  /* the handset is 2.3x taller than it is wide; a 15rem cap cut 25px off its
     top and 54px off its bottom. it is an object, not a plate. */
  .services .service-media:not(.shot-frame):not(.phone-object) { max-height: 15rem; }
}
/* the plate is cropped by the cap, not squashed — background-size:cover already
   handles the raster ones, and the box keeps its own aspect below the cap */
.web-existing .slab-wrap .frame-box { overflow: hidden; }

/* v16e: the slab plate holds a real screenshot of veil, in ITS dark mode, at a
   true 16:9 — the first capture was an arbitrary crop and read as a snapshot
   pasted on the page. Owner: "either lower the opacity and add other effects to
   make it look more like a part of the website, or screenshot it with the veil
   site in dark mode".

   Both, in the end. The asset is graded toward this palette at export
   (saturation 72%, 14% warm tint toward --void) so a violet-accented site sits
   next to a red-accented one without fighting it. Everything below is the
   integration: the frame is a screen, not a photo — inset light along the top
   edge, the site's own dot screen laid over it at low alpha, and a vignette
   that lets the plate fall into the section rather than stopping at a border. */
.slab-wrap { margin: 0; }
.slab-cap {
  margin-top: 10px; font-family: var(--f-mono); font-size: var(--t-code);
  letter-spacing: .06em; color: var(--fg-small-muted); max-width: 42ch;
}

/* ---- v16f: WCAG AA fixes from the scored review -------------------------
   Four numerals were coloured with accent tokens the stylesheet itself labels
   NEVER TEXT, so they broke the project's own oldest rule. I introduced the
   first one in v16c when the service numeral stopped being a decorative
   watermark and became a real mono label — the moment it became text, the
   accent stopped being legal on it.

   The reviewer proposed --ash for all four. I recomputed every pair by hand
   before applying and --ash on --paper is 1.01:1 — it would have made the
   service numeral invisible. Theme-correct tokens instead, each verified:
     .svc-num   on --paper : --ink-muted #3A3227  4.83:1  PASS
     .why-num   on --void  : --ash       #A9A093  7.16:1  PASS
     .fork__num on --void  : --ash       #A9A093  7.16:1  PASS
   Was: 3.17:1, 2.23:1, 3.12:1 — all failing.
   The red stays where it is legal: lamps, rules, needles and the dot. */
.services .service > .svc-num { color: var(--ink-muted); }
.why-item .why-num { color: var(--ash); }
.fork__num { color: var(--ash); }

/* v16f cont. — the rest of the scored review's a11y items.
   Touch targets: the nav links measured 43px and the wordmark 24px. Real
   WCAG 2.2 SC 2.5.8 wants 24px and they passed it, but 44px is the convention
   every checklist actually tests, and 43 vs 44 is not worth arguing about.
   The footer stack replaces an inline style attribute that was doing layout —
   banned on this project because inline outranks every stylesheet rule. */
.nav-pill__links a, .nav-pill__word {
  min-height: 44px; display: inline-flex; align-items: center;
}
/* this used to re-stack the pair vertically and range them left, which is what
   left the language box and the sound toggle stranded below the columns. the
   row above owns the layout now; only the wrap behaviour lives here. */
.site-footer .gc-9-13 { flex-wrap: wrap; }
/* the 404 fallback line was --ash-dim at 3.32:1 — legal for a border, not for
   a sentence telling someone how to reach a business */
.mailto-fallback span { color: var(--fg-small-muted); }   /* theme-aware: --ash on dark was 1.01:1 on paper */

/* ---- v16g: the browser plates hold real sites now --------------------------
   Audit finding 3: ".browser-plate = three traffic-light dots + a rounded
   address bar + four grey bars of decreasing width. On the page selling
   websites, the picture of a website is a grey-box placeholder." It was a
   loading skeleton, which is the exact "renders as a grey box" the brief bans.

   The chrome stays — the dots and the address bar are what say "this is a
   website" — and the four bars are replaced by an actual screenshot of a site
   we built, graded toward this palette the same way the veil plate is. Two
   different sites across the two pages so it does not read as one asset reused. */
.browser-plate { overflow: hidden; }
.browser-plate { display: flex; flex-direction: column; }

/* v16h cont. — the box was doing a real job, so replace the job, not just the box.
   With the full-width band gone the lead sits straight on the halftone: I sampled
   the brightest pixel behind it at 229/255 and --ash against that is 2.05:1.
   Unreadable, and the reason the band existed.

   So the type gets a card and the picture keeps its strength everywhere else —
   a soft radial that is opaque under the words and gone by its edge, sized to
   the text rather than to the viewport. No straight edge anywhere, so there is
   nothing that reads as a box. The h1 is 60px and sits over the darker left of
   the frame, so it needs only a shadow. */
/* A pseudo-element at z-index:-1 was painting behind .hero-content's own
   stacking context and never reached the screen — measured: the backdrop under
   the lead stayed at 230/255 after adding it. A background on the element
   itself cannot be out-stacked by anything. */
.hero-home .lead, .hero-home .hero-mono {
  position: relative;
  padding: 14px 22px 16px 18px;
  /* the card is pulled left so its edge lines up with the headline's optical
     left, but at phone widths that pushes it onto the viewport edge — measured
     L0 at 390px. Only pull it where there is inset to spend. */
  margin-left: 0;
  border-radius: 3px;
  /* the fade has to happen OUTSIDE the glyphs. An earlier version dropped to
     .34 alpha at 88% of the box, which is still over text — sampling the worst
     pixel there gave 2.05:1. Opaque across the whole measure, soft only in the
     last few percent where there is nothing to read. */
  background: radial-gradient(140% 180% at 42% 50%,
    rgba(23,19,14,.95) 0%, rgba(23,19,14,.93) 72%, rgba(23,19,14,.78) 92%, rgba(23,19,14,0) 100%);
}
.hero-home .hero-mono { display: inline-flex; }
@media (min-width: 900px) {
  .hero-home .lead, .hero-home .hero-mono { margin-left: -18px; }
}
.hero-home h1 { text-shadow: 0 2px 26px rgba(10,8,6,.85), 0 0 60px rgba(10,8,6,.6); }

/* ---- reduced motion: master kill switch --------------------------------- */
@media (prefers-reduced-motion: reduce) {
  * { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; scroll-behavior: auto !important; }
  .two-tone .ln { transform: none; }
  .nav-pill.hide { transform: none; }
  .work-tile { transform: none !important; }
}

/* ---- utility -------------------------------------------------------------*/
.visually-hidden {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0,0,0,0); white-space: nowrap; border: 0;
}
.sound-toggle {
  display: inline-flex; align-items: center; gap: 8px; background: transparent; border: 1px solid var(--border);
  border-radius: 999px; min-height: 44px; padding: 0 16px; font-family: var(--f-mono); font-size: var(--t-meta);
  text-transform: uppercase; letter-spacing: .06em; color: var(--fg-small-muted); cursor: pointer;
}

/* ---- empty state: the frames with no media in them yet --------------------
   v8 filled these with a 26px grey circle at 35% opacity, which is exactly the
   "generic placeholder glyph" the doctrine calls out. This is a contact-sheet
   blank instead: pitch field, blueprint grid at a 48px pitch, and a mono slug
   in the corner that says which frame it is and that nothing is in it yet. */
.object-plate {
  position: relative; overflow: hidden;
  min-height: clamp(120px, 16vh, 210px);
  background:
    repeating-linear-gradient(0deg,  var(--edge) 0 1px, transparent 1px 48px),
    repeating-linear-gradient(90deg, var(--edge) 0 1px, transparent 1px 48px),
    var(--pitch);
}
.object-plate > svg { display: none; }
.object-plate::after {
  content: attr(data-slug);
  position: absolute; left: 12px; bottom: 10px;
  font-family: var(--f-mono); font-size: var(--t-meta); letter-spacing: .08em;
  text-transform: uppercase; color: var(--ash);        /* 7.16:1 on --pitch+ */
}
/* one corner tick, so the field reads as a frame and not as a texture swatch */
.object-plate::before {
  content: ""; position: absolute; right: 14px; top: 14px; width: 18px; height: 18px;
  border-top: 1px solid var(--on-air); border-right: 1px solid var(--on-air); opacity: .8;
}
.service-media.frame-box { min-height: clamp(160px, 22vh, 280px); }
.service-media > svg { display: none; }

/* ---- scroll-scrubbed 3D object (MID tier) -------------------------------- */
.object-3d { position: relative; display: grid; place-items: center; background: none; }          /* drop the placeholder dot */
.object-3d > svg { display: none; }
/* v18d: .object-3d__frame is gone with the sprite loader that injected it.
   The camera is a plain <img class="cam-still__body"> in the markup now. */
.cam-still__body {
  width: 100%; height: 100%;
  object-fit: contain; object-position: center;
  filter: drop-shadow(0 24px 40px rgba(0,0,0,.55));
}

/* ---- the laptop: one frame, and a screen that is genuinely in it ---------
   aspect-ratio is the SOURCE frame's, 1100x786 — not the old 1400/1000, which
   was wrong by 8% and letterboxed the artwork inside its own box. With the box
   at the frame's ratio the image fills it exactly, so a percentage rect on this
   element lands on the same pixels of the photograph at every viewport width.
   That is the only reason the numbers below can be trusted. */
/* aspect-ratio is laptop-front.webp's own, 683x624. That file is lp_029 cropped
   to its artwork: the render sat in a 1100x786 canvas of which the machine
   occupied 60% of the width, so 40% of every box this thing was given went to
   transparent margin — which is most of why the owner kept reading it as small
   and adrift no matter how much room it was handed. Cropped, the same box now
   holds ~45% more laptop. */
.laptop-seq { position: relative; width: 100%; aspect-ratio: 683 / 624; }
/* Below 900px the stage is deliberately unstuck, so initLaptopStage() cannot
   scrub — the object was simply a still photograph on every phone. It gets its
   own entry move at those widths instead: it rises and settles as it comes into
   view. Same --vp fallback as the handset, so it moves on iOS too. */
@media (max-width: 900px) and (prefers-reduced-motion: no-preference) {
  @keyframes laptop-arrive {
    from { transform: translateY(26px) scale(.965); opacity: .55; }
    to   { transform: translateY(0)    scale(1);    opacity: 1; }
  }
  .laptop-seq { will-change: transform; }
  @supports (animation-timeline: view()) {
    .laptop-seq {
      animation: laptop-arrive cubic-bezier(.22,1,.36,1) both;
      animation-timeline: view();
      animation-range: entry 6% cover 38%;
    }
  }
  @supports not (animation-timeline: view()) {
    .laptop-seq {
      transform: translateY(calc(26px - min(var(--vp, 1), .5) * 52px))
                 scale(calc(.965 + min(var(--vp, 1), .5) * .07));
    }
  }
}
.laptop-seq__body {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: contain; object-position: center;
  filter: drop-shadow(0 30px 46px rgba(0,0,0,.62));
  transform: scale(var(--push, 1)); transform-origin: 50% 62%;
}
/* MEASURED off the pixels, then mapped through the crop: the dark LCD runs
   x 345..725 of 1100 and y 145..449 of 786 in the source render, which is
   x 124..504 of 683 and y 42..346 of 624 here. A 5:4 panel — which is why the
   plate that goes in it is cropped 5:4 and not the 16:10 the old asset was.
   Do not "tidy" these to round numbers; they are pixel positions, not taste. */
/* v18b — "it still looks like an overlay". It did, and for four reasons that
   have nothing to do with where the box is:
     · it sat flush to the measured rect, so its hard edge landed ON the bezel's
       inner lip instead of under it. Inset by 0.5% and the lip now overlaps it,
       which is the single strongest cue that a thing is BEHIND something.
     · a 1-bit plate is pure #000/#fff. Nothing in a photographed room is pure
       anything. The grade below lifts the black to a lit-panel black and pulls
       the white down off the ceiling, so the picture belongs to the same
       exposure as the plastic around it.
     · no glass. A screen in a lit room carries a sheen; without one it reads as
       a printed sticker.
     · square corners. This panel's corners are radiused in the render.
   None of it is a filter on an image — the grade and the sheen are their own
   layers so the picture underneath stays a picture. */
.laptop-seq__screen {
  /* MEASURED off the photograph, corner by corner, not assumed. The lid is
     tilted back and shot slightly off-axis, so the panel is a TRAPEZOID: the
     top edge spans 56.22% of the unit and the bottom only 54.17%, and both
     edges tilt. The old box was an axis-aligned rectangle inside that shape,
     which is why the picture never reached the bezel and never sat at the
     screen's angle. The box is the quad's true bounding box now and the
     clip-path is the quad itself, in percentages, so it holds at any size. */
  position: absolute; left: 17.86%; top: 6.41%; width: 56.22%; height: 49.68%;
  z-index: 2; overflow: hidden; pointer-events: none;
  clip-path: polygon(0% 1.29%, 100% 0%, 98.18% 97.74%, 1.82% 100%);
  border-radius: 2px;
  /* The body carries the push-in dolly and this is its SIBLING, not its child,
     so while --push ran 1 to 1.04 the machine grew and the picture stayed put —
     the screen visibly slid off the lid as you scrolled. Owner: "it also moves
     without moving with animation." Same transform, same origin, so they are
     one object again. */
  transform: scale(var(--push, 1));
  /* the body scales about 50%/62% of the WHOLE unit; this box is inset
     17.86%/6.41% and is 56.22%/49.68% of it, so the same point in this box's
     own coordinates is 57.16%/111.90%. Scaling about anything else lets the
     two creep apart as --push runs. */
  transform-origin: 57.16% 111.90%;
  /* the OFF state has to look like an off screen, not a hole cut in the lid:
     a cool dark panel with a gradient down it, so even before the strike the
     bezel is holding something. */
  background: linear-gradient(168deg, #23262b 0%, #14171b 46%, #0d0f12 100%);
  box-shadow:
    inset 0 0 0 1px rgba(0,0,0,.55),          /* the panel sits in its own well */
    inset 0 2px 7px rgba(0,0,0,.42);
}
/* the picture on the glass. --crt runs 0 (cold) to 1 (up), written by site.js:
   the raster opens from a centre line the way a CRT does, then the image sits.
   scaleY + opacity only, so the whole moment is compositor work. */
/* v18h — the picture on the screen is a WEBSITE. This laptop sits on the page
   that sells websites; a crowd on it was always answering the wrong question,
   which is why two different crowd plates in a row both read as "wrong". It is
   one of ours, shown whole rather than cover-cropped, so the machine is
   displaying a real page. */
.laptop-seq__glass {
  /* Clipping the box to the quad cut its outline but left the picture square to
     the viewer — a website floating flat on a lid that is plainly tilted.
     Measured off the photograph, the panel's left and right edges converge by
     1.31 and 1.32 degrees: symmetric, so it is PERSPECTIVE narrowing toward the
     bottom, not a shear. rotateX under a 900px perspective reproduces exactly
     that, and the -0.84deg is the lid's
     own tilt off horizontal.

     A full homography was tried first and left gaps: warping the box shrinks it
     inside the bezel. The box is oversized here instead and the container's
     clip-path trims it back to the quad, so the angle is right and there is
     nothing to leave a hole. */
  position: absolute; inset: -4% -3%; z-index: 0; display: block;
  backface-visibility: hidden;
  background-image: image-set(
    url("assets/examples/lukasz-home.webp") type("image/webp"),
    url("assets/examples/lukasz-home.png")  type("image/png"));
  /* cover, anchored to the top: a 16:9 capture in a 5:4 panel left a black
     band across the bottom third at `100% auto`, which reads as a broken
     screen. Filling it shows the page's head, which is what a screen shows. */
  background-size: cover; background-position: 50% 0;
  background-repeat: no-repeat;
  background-color: #0d0f12;
  /* the grade. A 1-bit plate has no midtones to lose, so this is doing real
     work: the whites come off paper-white and the blacks come off absolute, and
     the panel reads as emitting rather than as pasted. */
  /* it read as a picture pasted on the lid rather than a panel behind glass.
     A screen is lit from within and falls off at its own edges, so: lift the
     midtones instead of flattening them, and let the corners go down. */
  filter: contrast(1.02) brightness(1.04) saturate(0.94);
  /* ONE transform, because there can only be one. The perspective sat in its
     own declaration above and the raster's scaleY simply replaced it — the
     picture stayed square to the viewer and the taper never rendered. The
     CRT open and the lid's angle are the same chain now: project first, then
     open the raster inside that projection. */
  transform:
    perspective(900px)
    rotateX(-5.03deg)
    rotate(-0.84deg)
    scaleY(clamp(0.012, var(--crt, 0), 1));
  transform-origin: 50% 50%;
  opacity: clamp(0, var(--crt, 0) * 2.6, 1);
}
/* the sheen. One broad specular across the top-left of the glass, plus the
   bright hairline every panel has along its top edge. */
.laptop-seq__screen::before {
  content: ""; position: absolute; inset: 0; z-index: 3; pointer-events: none;
  background:
    linear-gradient(180deg, rgba(255,252,246,.16) 0 1px, rgba(255,255,255,0) 1px),
    linear-gradient(148deg,
      rgba(255,252,246,.15) 0%, rgba(255,252,246,.06) 28%,
      rgba(255,255,255,0) 56%);
}
/* The flare that rides the opening raster. `screen` is the correct blend for a
   light being added — and it is also why the scanlines cannot live on this
   element: screen-blending black is a no-op, so a ruling drawn here would
   render nothing at all. They get their own layer below, at normal blend. */
.laptop-seq__scan {
  position: absolute; inset: 0; z-index: 1; display: block; mix-blend-mode: screen;
  background: linear-gradient(180deg,
    rgba(255,246,228,0) 44%, rgba(255,246,228,var(--flare, 0)) 50%, rgba(255,246,228,0) 56%);
  opacity: clamp(0, var(--crt, 0), 1);
}
/* standing scanlines, plus the corner falloff every one of these panels had.
   Both are what stop a 1-bit halftone from reading as a blown-out white patch
   at this size — the ruling puts tone back between the dots. */
.laptop-seq__screen::after {
  content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none;
  background:
    repeating-linear-gradient(180deg, rgba(0,0,0,.16) 0 1px, rgba(0,0,0,0) 1px 3px),
    radial-gradient(120% 126% at 50% 44%, rgba(0,0,0,0) 48%, rgba(0,0,0,.30) 82%, rgba(0,0,0,.54) 100%);
  opacity: clamp(0, var(--crt, 0), 1);
}
/* No custom-property arithmetic at all (older Safari/Chromium): the screen is
   simply on. Degraded, never broken — and never an empty black rectangle. */
@supports not (opacity: clamp(0, (var(--crt, 0) - 0.5) / 0.3, 1)) {
  .laptop-seq__glass { transform: none; opacity: 1; }
  .laptop-seq__scan  { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .laptop-seq__glass  { transform: none; opacity: 1; }
  .laptop-seq__scan   { opacity: 1; }
  .laptop-seq__body   { transform: none; }
  .laptop-seq__screen { transform: none; }
}

/* ---- crowd dissolve canvas ----------------------------------------------
   The canvas re-screens the halftone photograph as a coarse dot field and then
   owns the crowd outright, so there is one material on screen rather than a
   photo with sparks coming off it. Same box, same transform as ::before, so
   the handover is pixel-for-pixel.

   color / caret-color are how site.js reads the palette without a hex in the
   JS: both serialise to a resolved colour through getComputedStyle, and
   neither is ever painted on a canvas. Repoint them and the dots follow.

   width/height are not decoration either. A canvas is a replaced element, so
   `inset:0` with `width:auto` resolves to the canvas's own attribute width and
   quietly drops `right` — the box would come out 300px wide, or DPR-times too
   wide once the backing store is set. It has to be told 100%. */
.crowd-dissolve {
  position: absolute; left: 0; top: 0; width: 100%; height: 100%;
  z-index: 2; pointer-events: none;
  color: var(--chalk);
  caret-color: var(--on-air-hot);
  transform: translate3d(0, var(--crowd-y, 0), 0) scale(1.02);
}
section[data-theme="paper"] .crowd-dissolve {
  color: var(--ink);
  caret-color: var(--on-air-ink);
}
/* the plate hands over only once the field has actually painted — if the image
   or the 2D context never arrives, the photograph stays and nothing is lost */
/* v15b — MEASURED, and it was the single largest frame cost on the home page.
   Once the canvas takes over, this layer was merely opacity:0 — still a
   full-viewport 4800x1380 image carrying a filter() and a mask, still in the
   layer tree, and still being invalidated every frame because the parallax rAF
   kept writing --crowd-y to it. Ablation at 1440: removing this element took
   the page from mean 82.20ms / p95 295.70ms to mean 32.72ms / p95 61.40ms.
   visibility:hidden after the fade means it is not painted at all, and
   initCrowdParallax now skips handed-over plates so nothing writes to it. */
.crowd-plate.has-dissolve::before {
  opacity: 0; visibility: hidden;
  transition: opacity 260ms linear, visibility 0s linear 260ms;
}

/* ---- camera flash -------------------------------------------------------- */
.flash-pop {
  position: absolute; inset: -12%; z-index: 3; pointer-events: none;
  border-radius: 50%; opacity: 0;
  background: radial-gradient(circle at 50% 45%,
    rgba(255,255,255,.95) 0%, rgba(255,255,255,.45) 22%, rgba(255,255,255,0) 62%);
}
.flash-pop.go { animation: flashPop 620ms cubic-bezier(.16,1,.3,1) 1; }
@keyframes flashPop {
  0%   { opacity: 0;   transform: scale(.72); }
  7%   { opacity: 1;   transform: scale(1.02); }
  22%  { opacity: .55; transform: scale(1.06); }
  100% { opacity: 0;   transform: scale(1.14); }
}
@media (prefers-reduced-motion: reduce) {
  /* the dot field stays — it is painted once, at rest, and never listens for
     anything. Hiding it would hand reduced motion a different composition
     rather than the same one holding still. */
  .crowd-dissolve { transform: none; }
}

/* ---- waveform spine removed 2026-08-09 (owner request), incl. its scrub
   control rules — the fixed right-edge strip no longer exists. ------------ */

/* ==========================================================================
   v9 — sprite stages, states, and the pieces the override layer took out.
   The four stacked FIX/PASS banners that used to live here are gone: they
   each reverted a doctrine requirement to fix one screenshot, and between
   them they put back the centred 1440px column, repainted the black hero
   cream, cut the type scale 40% and unhooked every rendered asset. What
   those blocks were reaching for is now solved above, in the base layer,
   once.
   ========================================================================== */

/* contact form — real fields, laid out as a block that fills its column */
.contact-form {
  grid-column: 1 / 7; display: grid; gap: clamp(12px, 1vw, 18px);
  grid-template-columns: 1fr 1fr; margin-top: clamp(20px, 1.7vw, 30px);
}
.field { display: flex; flex-direction: column; gap: 6px; }
.field--wide { grid-column: 1 / -1; }
.contact-form label {
  font-family: var(--f-mono); font-size: var(--t-meta);
  text-transform: uppercase; letter-spacing: .08em; color: var(--fg-muted);
}
.contact-form input, .contact-form textarea {
  background: transparent; color: var(--fg);
  border: 1px solid var(--border); border-radius: 2px;
  padding: .7em .8em; font-size: var(--t-body); min-height: 44px;
}
.contact-form textarea { resize: vertical; min-height: 110px; }
.contact-form input:focus-visible, .contact-form textarea:focus-visible {
  outline: 2px solid var(--focus); outline-offset: 2px; border-color: var(--focus);
}
.form-note { grid-column: 1 / -1; color: var(--fg-muted); margin: 0; }
.form-actions {
  grid-column: 1 / -1; display: flex; align-items: center;
  gap: clamp(20px, 1.7vw, 30px); flex-wrap: wrap; margin-top: 10px;
}
.mailto-fallback { color: var(--fg-body); }   /* --fg-muted is --ash-dim on dark: 3.32:1, borders only */
/* !important because the hand-set spans live in inline style attributes on the
   markup, and an inline declaration outranks a stylesheet rule in any media
   query. Without it a 32px h2 stays trapped in 5 of 12 columns at 320px. */
@media (max-width: 700px) {
  .contact-form { grid-template-columns: 1fr; grid-column: 1 / -1; }
  .section .grid > *, .contact-grid > * { grid-column: 1 / -1 !important; }
  .section .grid > * { --oy: 0px !important; }
}

/* ---- a third surface: the recessed band ---------------------------------
   paper → void → paper → void is still only two exposures. --pitch is a
   half-stop under the ground colour, used once per page, so the facts strip
   reads as a well cut into the page rather than another section. */
section[data-theme="pitch"] {
  --bg: var(--pitch); --bg-hi: var(--void);
  --fg: var(--chalk); --fg-body: var(--ash); --fg-muted: var(--ash-dim);
  --fg-small-muted: var(--ash);
  --fg-fade: var(--ash); --link: var(--chalk); --rule: var(--edge-pitch);
  --accent-ink: var(--on-air); --accent: var(--on-air); --border: var(--ash-dim);
  --btn-fill: var(--on-air-hot); --btn-text: var(--void);
  --focus: var(--chalk);
  background: var(--bg); color: var(--fg-body);
  padding-block: var(--pad-dark);
}
section[data-theme="pitch"] h1, section[data-theme="pitch"] h2, section[data-theme="pitch"] h3 { color: var(--fg); }

/* ---- v13: the bar — a full-bleed hairline that gives two same-theme
   surfaces a boundary without a seam. One element, three theme colours;
   never red on paper. */
.bar {
  height: 1px; background: var(--rule); width: 100vw;
  margin-inline: calc(50% - 50vw); pointer-events: none;
}
section[data-theme="dark"] .bar { background: var(--edge); }
section[data-theme="pitch"] .bar { background: var(--edge-pitch); }

/* ---- v14: the ticker band — a CSS-only marquee of real facts. It fills the
   breathing room a shorter hero leaves behind with signal, not decoration:
   stations, artists, labels, milestones — all researched, nothing invented.
   Theme-aware: mono slugs, ash on dark, ink-muted on paper (4.83:1 / 7.9:1).
   One rAF-free animation; under reduced motion it is a static ruled line. */
/* v16: the marquee ticker was removed from every page — it was aria-hidden
   furniture repeating names the facts strip already carries, on a permanently
   running animation. These rules are dead; delete the block on next pass. */
.ticker--removed, .ticker {
  position: relative; overflow: hidden; border-top: 1px solid var(--rule);
  border-bottom: 1px solid var(--rule); padding-block: 10px;
}
.ticker__track {
  display: flex; gap: 0; width: max-content;
  font-family: var(--f-mono); font-size: var(--t-meta); letter-spacing: .08em;
  text-transform: uppercase; color: var(--fg-small-muted);
}
.ticker__track > span { display: inline-flex; align-items: center; gap: 12px; padding-right: 12px; white-space: nowrap; }
.ticker__track > span::after { content: "●"; color: var(--on-air); font-size: .72em; padding-left: 12px; }
.ticker__track > span:last-child::after { content: none; }
.ticker__track.repeat {
  animation: ticker-slide var(--ticker-dur, 42s) linear infinite;
}
@keyframes ticker-slide { to { transform: translateX(-50%); } }
@media (prefers-reduced-motion: reduce) {
  /* static ruled band; the overflow moves to the band itself so the second
     half stays reachable instead of clipped by the hidden overflow */
  .ticker__track.repeat { animation: none; }
  .ticker { overflow-x: auto; }
}
@media (max-width: 640px) { .ticker { padding-block: 8px; } }

/* the dark CTA hover: same red, one notch hotter, no stray hex */
section[data-theme="dark"] .btn:hover,
section[data-theme="pitch"] .btn:hover {
  background: var(--on-air-hot); filter: brightness(1.1);
  box-shadow: 0 0 0 1px var(--chalk) inset, 0 10px 22px rgba(0,0,0,.28);
}

/* ---- sprite stages: the 66 rendered frames, finally referenced -----------
   assets/camera (36 frames) and assets/laptop (30) were baked months ago and
   nothing in the markup ever carried a data-seq, so initObjectSequences()
   returned on its first line and the whole MID tier was dead code. */
/* v18 — this is the "lines through the objects" the owner photographed. A 48px
   repeating grid was painted as the BACKGROUND of every sprite plate, and the
   sprites are transparent PNGs, so the ruling ran straight through the camera
   body and the laptop lid. At the plate's old small size it read as texture; at
   the size these boxes grew to, it reads as a fault. A single soft pool of
   light instead: it says "object on a surface" without drawing on the object. */
.object-3d {
  position: relative; overflow: hidden;
  background-color: var(--pitch);
  background-image: radial-gradient(120% 92% at 50% 108%,
    color-mix(in srgb, var(--chalk) 9%, transparent) 0%,
    color-mix(in srgb, var(--chalk) 3%, transparent) 44%,
    transparent 72%);
}
/* ---- v15: the camera's tally ---------------------------------------------
   site.js writes --cam-lit 0->1 as the still arrives.
   The rim and the lamp come up together: the camera goes ON AIR as it arrives.
   Both are opacity on a fixed-colour layer, so this costs no repaint of the
   image and no layout. --cam-lit has a 0 default, so a browser that never runs
   the JS shows an unlit camera rather than a permanently lit one. */
.service-media.object-3d { --cam-lit: 0; }
.service-media.object-3d::before {
  content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none;
  box-shadow: inset 0 0 0 1px var(--on-air), inset 0 0 26px -6px var(--on-air);
  opacity: calc(var(--cam-lit, 0) * 0.85);
}
.service-media.object-3d .cam-lamp {
  position: absolute; right: 12px; top: 12px; z-index: 4;
  width: 8px; height: 8px; border-radius: 50%;
  background: var(--on-air); box-shadow: 0 0 12px var(--on-air);
  opacity: calc(var(--cam-lit, 0) * 1);
}
@media (prefers-reduced-motion: reduce) {
  .service-media.object-3d::before, .service-media.object-3d .cam-lamp { opacity: 0; }
}
/* loading: a hairline that says what it is waiting for, and how far in it is.
   Removed the moment the gate opens; never drawn under reduced motion. */
.object-3d::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: 0; z-index: 4; height: 2px;
  transform-origin: left; transform: scaleX(var(--ready, 0));
  background: var(--on-air); transition: transform 120ms linear;
}
.object-3d[data-ready="1"]::after, .object-3d[data-tier="low"]::after { content: none; }
@media (prefers-reduced-motion: reduce) { }

/* ---- websites: the laptop opening ---------------------------------------
   30 frames over a 220svh sticky stage. Lid shut at frame 0, open at 29, code
   typing onto the screen from frame 22. This is the one moment on the site. */
/* v14: laptop cut 220svh -> 160svh — the open moment lands sooner; the stage
   still needs > 100svh of travel for the lid's frames to scrub. */
/* v14: was 160svh — 1728px of section holding 1080px of laptop at 1920, a
   measured 648px void, and the scrub had that much nothing to travel through.
   The stage still needs scroll distance to scrub the lid open, so it keeps a
   generous but bounded height instead of a viewport multiple. */
/* ---- v15: the 100px bug --------------------------------------------------
   MEASURED: at 1440x900 the section was min(118svh, 1000px) = 1000px tall
   while .laptop-stage inside it is position:sticky;height:100svh = 900px.
   stageProgress() = -rect.top / (rect.height - innerHeight) = 1000 - 900 =
   100px. The entire scene — 30 lid frames, the welded screen, the tally lamp
   and a 7-item reveal — was scrubbing across ONE HUNDRED PIXELS of scroll.
   That is the whole of "the laptop animation must be smoother", and it is also
   why the band measured 460px of void at 1440 and 707px at 390: the height was
   not being used as travel, so it was just emptiness.

   260svh capped at 2200px gives 1300px of travel at a 900px viewport — x13.
   The lid arc (0.08 -> 0.62 of progress) goes from 54px of scroll to 702px. */
/* v16: was 260svh/2200px — 2200px of scroll for a 30-frame lid, about 73px of
   scrolling per frame, which is also why the lid felt sluggish. 150svh gives
   ~23px per frame: still smooth, and 600px shorter — without starving the
   scrub the way the hero got starved. */
/* Travel = section height - stage height. At 175svh/1465px that was 565px for
   the whole scene, and the last third of it was the stage un-pinning, so the
   raster finished while the laptop was already sliding out of frame. 215svh
   caps the pinned window at ~1035px of scroll at a 900px viewport: the strike
   lands, the picture settles, the seven rows arrive, and only then does the
   band let go. The old warning about "too much travel feels sluggish" was
   written when 30 webp decodes were bound to this scrollbar; a scaleY does not
   care how far it is dragged. v18b: 215svh was 1800px of section for one scene
   and the owner is asking for less page, not more. 168svh still leaves ~610px
   of pinned travel at a 900px viewport — the strike, the settle and seven rows
   all land inside it, because the timings were pulled forward to match. */
.laptop-stage-section { min-height: min(168svh, 1420px); }
.laptop-stage-section[data-theme="dark"] { background: var(--pitch); }
/* Without sticky the tall section IS a void, so old browsers get the short
   version and a laptop that simply sits there open. Degraded, never broken. */
@supports not (position: sticky) {
  .laptop-stage-section { min-height: auto; }
  .laptop-stage { position: static; height: auto; padding-block: var(--pad-dark); }
}
@media (max-width: 900px) {
  /* phones do not get a 2200px sticky scene: it is 2.4 screens of scroll on a
     device where the lid frames are already halved by the MID tier. */
  .laptop-stage-section { min-height: auto; }
  .laptop-stage {
    position: static; height: auto; padding-block: var(--pad-dark);
    grid-template-columns: repeat(12, minmax(0, 1fr));
    grid-template-rows: none;
  }
  /* trap #4: this span was set with no reset below 900px, so the h2 rendered
     111px wide at 390px and "kaj ponujamo" broke over two lines in a track
     four columns wide. v18: grid-row has to be released too, or the plate and
     the caption both try to occupy row 1 of a stack that no longer has rows. */
  .laptop-stage .offer-pin,
  .laptop-stage .plate-wrap,
  .laptop-stage .stage-cap { grid-column: 1 / -1; grid-row: auto; }
  .laptop-stage .plate-wrap { height: auto; min-height: 0; margin-top: 28px; display: block; }
  .laptop-stage .plate-wrap .laptop-seq { max-width: 100%; }
  .laptop-stage .stage-cap { text-align: left; margin-top: 12px; }
}
/* v18: the duplicate `.laptop-seq { aspect-ratio: 1400/1000 }` that used to sit
   here is gone — it was a second, WRONG ratio overriding the right one 330
   lines above it, and the absolute/inset override under it is what let the
   collapsed row squash the unit. One definition, near the artwork it describes.
   The tally lamp sits on the lid's top-right corner of lp_029, not the box's. */
.laptop-seq .lamp {
  position: absolute; right: 18.89%; top: 9.29%; z-index: 3; width: 8px; height: 8px; border-radius: 50%;
  background: var(--on-air); opacity: .12; transition: opacity 140ms var(--ease-out);
}
.laptop-seq[data-lit="1"] .lamp { opacity: 1; box-shadow: 0 0 12px var(--on-air); }

/* ---- examples: four frames, four different crops (v14: fixed heights) ---- */
/* v16: four different heights fought the shared aspect-ratio. One ratio wins. */
.examples-grid .example-frame { height: auto; aspect-ratio: 16 / 9; }

/* ---- narrow-viewport overflow containment (measured at 320px) ----------- */
@media (max-width: 480px) {
  .services .service,
  .services .service--link.asym { grid-column: 1 / -1 !important; }
  .service-media { width: 100%; max-width: 100%; }
  .laptop-stage .plate-wrap { height: auto; }
  /* tile widths stay uneven here — the rail is overflow-x:auto, so a tile can
     never push the page. Only the caption needs tightening. */
  .work-tile .frame-num { bottom: -20px; font-size: 10px; }
}

/* v18: the old screen-crowd.webp block is gone. That asset was a 16:10 crop
   dropped into a 5:4 panel — so it was cover-cropped a second time and the
   1-bit threshold at that size came out as static, not people. The plate it is
   replaced by (screen-plate.webp) is cropped to the panel's real 5:4 and
   resampled DOWN from the 4800px negative, which is what turns a hard 1-bit
   halftone back into readable greys. It is declared with the screen itself,
   above, rather than 340 lines away from it. */

/* ---- edge glow (v14: scrapped and rebuilt) ----------------------------------
   Third attempt, and the first one that is actually a rim light. v10 painted a
   radial spotlight AT the cursor — a smudge dragged across the artwork. v12
   moved the anchor to the nearest edge point but still painted a 150px radial
   INSIDE the surface, so it was a smaller smudge in a better place.

   This version paints the gradient into the BORDER ONLY. The overlay is a 1.5px
   frame; a mask keeps the padding-box hole punched out, so the gradient can
   only ever appear on the ring itself. There is no fill to smudge. The light
   travels along the edge nearest the pointer and falls off with distance, which
   is what "the edge glows as you approach" was always supposed to mean.

   --gx/--gy are written in px by the JS, --go is 0..1 intensity. Opacity is the
   only animated property. Old browsers without mask-composite fall through to
   the @supports block: a plain hairline that brightens — degraded, not broken. */
.glow-surface > .glow, .service-media > .glow, .reel-plate > .glow,
.rek-wave > .glow, .frame-plate-box > .glow,
.frame-box > .glow, .laptop-seq > .glow, .example-frame > .glow {
  position: absolute; inset: -1px; z-index: 3; pointer-events: none;
  border-radius: inherit;
  padding: 1.5px;
  background:
    radial-gradient(220px 220px at var(--gx, 50%) var(--gy, 50%),
      var(--glow-tint, rgba(214, 92, 82, .95)) 0%,
      color-mix(in srgb, var(--glow-tint, #D65C52) 38%, transparent) 42%,
      transparent 74%);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
          mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
          mask-composite: exclude;
  opacity: var(--go, 0);
  transition: opacity 260ms var(--ease-out);
  will-change: opacity;
}
/* A second, much wider and much fainter pass that spills OUTSIDE the frame, so
   the rim reads as light leaving the object rather than a stroke sitting on it.
   Unmasked on purpose — at 0.16 alpha over 300px it cannot register as a smudge. */
.glow-surface > .glow::after, .service-media > .glow::after, .reel-plate > .glow::after,
.rek-wave > .glow::after, .frame-plate-box > .glow::after,
.frame-box > .glow::after, .laptop-seq > .glow::after, .example-frame > .glow::after {
  content: ""; position: absolute; inset: -26px; border-radius: inherit;
  background: radial-gradient(300px 300px at var(--gx, 50%) var(--gy, 50%),
    color-mix(in srgb, var(--glow-tint, #D65C52) 16%, transparent) 0%, transparent 68%);
}
@supports not ((mask-composite: exclude) or (-webkit-mask-composite: xor)) {
  .glow-surface > .glow,
  .frame-box > .glow, .laptop-seq > .glow, .example-frame > .glow {
    background: none; padding: 0;
    box-shadow: inset 0 0 0 1px var(--glow-tint, #D65C52);
  }
  .glow-surface > .glow::after { display: none; }
}
:root { --glow-tint: rgba(214, 92, 82, .95); }
section[data-theme="paper"] { --glow-tint: rgba(120, 58, 40, .9); }
@media (prefers-reduced-motion: reduce) { .glow-surface > .glow, .frame-box > .glow, .laptop-seq > .glow, .example-frame > .glow { display: none; } }

/* ---- scrollbars: one custom treatment, everywhere -------------------------
   The site had the browser default on the page and a thin grey default on the
   work rail. Both read as unfinished next to the rest of the chrome. This is
   the same object in both palettes: a 10px track with a hairline, a thumb that
   is a soft-edged bar, and a hover state that answers. Firefox gets the
   scrollbar-width/scrollbar-color pair, which is all it supports. */
html { scrollbar-width: thin; scrollbar-color: var(--ash-dim) transparent; }
* { scrollbar-width: thin; scrollbar-color: var(--ash-dim) transparent; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
  background: color-mix(in srgb, var(--ash-dim) 78%, transparent);
  border: 3px solid transparent; background-clip: content-box; border-radius: 99px;
  transition: background-color var(--dur-ui) var(--ease-out);
}
::-webkit-scrollbar-thumb:hover { background: var(--ash); background-clip: content-box; }
::-webkit-scrollbar-thumb:active { background: var(--on-air-hot); background-clip: content-box; }
::-webkit-scrollbar-corner { background: transparent; }
[data-theme="paper"] ::-webkit-scrollbar-thumb { background: color-mix(in srgb, var(--stone) 85%, transparent); background-clip: content-box; }
[data-theme="paper"] ::-webkit-scrollbar-thumb:hover { background: var(--ink-muted); background-clip: content-box; }

/* ---- custom cursor (v12) ----------------------------------------------------
   dot + lagging ring, only under (hover:hover) and (pointer:fine); the
   browser cursor is hidden by the .cursor-custom class on <html> (set from
   JS), so a JS failure never leaves a pointerless page. The ring uses
   mix-blend-difference on white, so one colour works on both palettes. */
html.cursor-custom, html.cursor-custom * { cursor: none; }
/* v14: position comes in as --cx/--cy and scale as --cs, composed here. It used
   to be an inline `transform: translate3d(...)` written from JS, which outranks
   the stylesheet — so every [data-state] scale rule below was dead code and the
   ring never actually reacted to anything. That was one of the "animations that
   just don't work". Now JS only writes the two position variables. */
.cursor-dot, .cursor-ring {
  position: fixed; top: 0; left: 0; pointer-events: none; z-index: 9999;
  border-radius: 50%; opacity: 0;
  transform: translate3d(var(--cx, -100px), var(--cy, -100px), 0) scale(var(--cs, 1));
}
.cursor-dot {
  width: 5px; height: 5px; margin: -2.5px 0 0 -2.5px;
  background: var(--on-air-hot); box-shadow: 0 0 8px var(--on-air);
  transition: opacity 160ms linear, width 200ms var(--spring), height 200ms var(--spring);
}
.cursor-ring {
  width: 30px; height: 30px; margin: -15px 0 0 -15px;
  border: 1.5px solid #fff; mix-blend-mode: difference;
  transition: opacity 160ms linear, border-width 220ms var(--ease-out);
}
html.cursor-on .cursor-dot, html.cursor-on .cursor-ring { opacity: 1; }
/* over something clickable the ring opens up and thins out — it reads as the
   cursor making room for the target rather than sitting on top of it */
.cursor-ring[data-state="hot"]  { --cs: 1.75; border-width: 1px; }
.cursor-ring[data-state="drag"] { --cs: 2.1;  border-width: 1px; border-style: dashed; }
.cursor-ring[data-state="text"] { --cs: 0.42; }
.cursor-ring[data-state="down"] { --cs: 0.82; border-width: 2px; }
.cursor-dot[data-state="text"]  { width: 2px; height: 21px; margin: -10.5px 0 0 -1px; border-radius: 1px; }
.cursor-dot[data-state="down"]  { --cs: 1.9; }
@media (prefers-reduced-motion: reduce) { .cursor-dot, .cursor-ring { display: none; } }

/* ---- browser window plate (home services: websites tile, v12) --------------
   The static laptop tile was removed (owner: the laptop belongs to the
   websites page only). Its replacement is a browser window drawn in CSS —
   three chrome dots, a URL bar, and content rows — an honest placeholder in
   the same built-not-scavenged language as the other plates. */
.browser-plate {
  position: relative; overflow: hidden;
  background: var(--pitch);
  display: flex; flex-direction: column; gap: 0;
  padding: 14px 14px 0;
}
.browser-plate__dots { display: flex; gap: 6px; padding-bottom: 10px; }
.browser-plate__dots span { width: 8px; height: 8px; border-radius: 50%; }
.browser-plate__dots span:nth-child(1) { background: var(--on-air); }
.browser-plate__dots span:nth-child(2) { background: var(--ash-dim); }
.browser-plate__dots span:nth-child(3) { background: var(--ash-dim); }
.browser-plate__bar {
  height: 22px; margin-bottom: 12px; border-radius: 999px;
  background: var(--carbon); border: 1px solid var(--edge);
}
/* v18: `flex: 1 1 auto` on the group and on the last block, so the drawn page
   fills the window instead of sitting as 60px of bars at the top of a 460px
   frame with nothing under them — which is what it did the moment this plate
   was given a real column to live in. */
.browser-plate__rows { display: flex; flex-direction: column; gap: 8px; padding-bottom: 14px; flex: 1 1 auto; min-height: 0; }
.browser-plate__rows span { height: 9px; border-radius: 2px; background: var(--ash-dim); opacity: .55; flex: 0 0 auto; }
.browser-plate__rows span:nth-child(1) { width: 82%; }
.browser-plate__rows span:nth-child(2) { width: 94%; }
.browser-plate__rows span:nth-child(3) { width: 66%; }
/* the block is the page's hero image, so it reads as a surface rather than as
   a fourth bar: the same halftone the rest of the site prints, knocked back. */
.browser-plate__rows span:nth-child(4) {
  width: 100%; min-height: 48px; margin-top: 8px; flex: 1 1 auto; opacity: 1;
  background:
    linear-gradient(0deg, rgba(23,19,14,.62), rgba(23,19,14,.62)),
    url("assets/crowd/crowd-halftone-invert.webp");
  background-size: auto, cover;
  background-position: 0 0, 42% 46%;
}

/* ---- scroll progress (state visibility, RESEARCH-claude-fable5-opus5.md) ----
   A 2px hairline at the top of the viewport: where you are, how much is left.
   One CSS var (--p, 0..1) drives both the line's scaleX and the leading-edge
   endcap — the VU needle motif from the facts rows, a playback head instead
   of a template progress bar. transform-only, written by a coalesced scroll
   listener, never under reduced motion. On paper sections the line turns
   ink; everywhere else it is the on-air red. */
.scroll-progress {
  position: fixed; top: 0; left: 0; right: 0; height: 2px; z-index: 90;
  transform-origin: left; transform: scaleX(var(--p, 0));
  background: var(--on-air);
  pointer-events: none;
  will-change: transform;
}
.scroll-progress[data-theme="paper"] { background: var(--ink); }
.scroll-progress::after {
  content: ""; position: absolute; top: 50%; left: calc(var(--p, 0) * 100%);
  width: 8px; height: 8px; transform: translate(-50%, -50%);
  background: inherit;
}

/* ---- 404 (failures soften, never silent) ----------------------------------
   The not-found page answers in the site's own voice with a way forward —
   home, or the email. Everything here rides the shared chrome; only the
   message block is new. */
.notfound .hero-mono { margin-top: 4vh; }
.notfound-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 20px 32px; margin-top: 32px; }

/* ==========================================================================
   REKLAME IN VSEBINE  (v18d — commercials has its own page)
   The 01 widget on the home page used to point at #contact because there was
   nowhere to send it. There is now, so the three service widgets are three
   real links to three real pages.
   ========================================================================== */
.rek-offer .offer-pin { margin-top: clamp(14px, 2.2vh, 26px); }
/* the list is printed here rather than revealed: this page has no sticky stage
   to scrub, so an item at opacity 0 waiting for a scroll signal that never
   arrives is exactly the bug the laptop band had on mobile. */
.rek-offer .offer-pin li { opacity: 1; transform: none; color: var(--chalk); }
.rek-offer .offer-pin li::before { color: var(--on-air-hot); }
.rek-offer .offer-pin li + li::after { transform: scaleX(1); }
.rek-offer__closer { align-self: end; color: var(--ash); }

.rek-voice h2 { margin-bottom: clamp(14px, 2.2vh, 24px); }
.rek-voice .body-text { color: var(--fg-body); }
.rek-voice__note {
  margin-top: 18px; color: var(--fg-small-muted);
  border-left: 2px solid var(--accent); padding-left: 14px;
  /* align-self:start, or the rule stretches to the height of the waveform
     beside it and runs 300px past the two lines it belongs to */
  align-self: start;
}
.rek-voice .waveform-contact {
  align-self: center; min-height: clamp(140px, 22vh, 230px);
  grid-row: 1 / 4;
}

/* the four steps: a ledger, not four equal cards. Each row is number, title,
   sentence on one line, with the rule belonging to the row above it. */
.rek-how h2 { margin-bottom: clamp(18px, 2.8vh, 34px); }
.rek-steps { list-style: none; margin: 0; padding: 0; }
.rek-step {
  display: grid; grid-template-columns: 56px minmax(0, 4fr) minmax(0, 7fr);
  gap: clamp(12px, 1.6vw, 28px); align-items: baseline;
  padding: clamp(16px, 2.4vh, 26px) 0;
}
.rek-step + .rek-step { border-top: 1px solid var(--edge); }
.rek-step__n { color: var(--on-air-hot); font-size: var(--t-meta); letter-spacing: .12em; }
.rek-step h3 {
  font-size: clamp(1rem, 0.82rem + 0.72vw, 1.375rem);
  line-height: 1.1; letter-spacing: -0.012em; color: var(--chalk);
}
.rek-step .body-text { color: var(--ash); margin: 0; }
@media (max-width: 760px) {
  .rek-step { grid-template-columns: 42px minmax(0, 1fr); row-gap: 6px; }
  .rek-step .body-text { grid-column: 2; }
}

/* the commercials hero plate. Same negative, same screen, same language as the
   rest of the site — one crop anchor and one aspect ratio different, which is
   how the other bands differ from each other too. */
.rek-wave { align-self: center; justify-self: stretch; }
/* Purpose-built rather than reusing .waveform-contact: that component declares
   its own 6.5:1 ratio and its bars came out as horizontal smears at hero size.
   The geometry here is baked into the markup from the same WAVEFORM_DATA table
   the rest of the site uses, so there is one envelope on the site and this one
   needs no JS at all. */
.rek-wave__svg {
  display: block; width: 100%; aspect-ratio: 1440 / 520;
  color: var(--ink-muted); opacity: .85;
}
.rek-wave__svg rect { transition: opacity var(--dur-ui) var(--ease-out); }
.rek-wave__cap {
  margin-top: 14px; text-align: right; color: var(--fg-small-muted);
  font-size: var(--t-stamp); letter-spacing: .24em; text-transform: uppercase;
}
/* retired with the plate it belonged to */
.rek-plate { display: none; }
@media (max-width: 900px) {
  .rek-wave { grid-column: 1 / -1; margin-top: 22px; }
  .rek-offer__closer { grid-column: 1 / -1; margin-top: 22px; }
}

/* the commercials hero. The plate is the object; the lead is set at heading
   scale for the same reason it is on the websites hero — a big picture on the
   right and two small paragraphs on the left is not a composition, it is a gap. */
body[data-page="reklame"] .web-hero .lead {
  font-size: clamp(1.0625rem, 0.8rem + 1.05vw, 1.75rem);
  max-width: 20ch;
  /* the accent rule is ::before on .web-hero .lead and it is inset from the
     element's own box — so without this the rule runs the full height of a
     stretched grid row instead of the height of the sentence */
  align-self: start;
}
body[data-page="reklame"] .web-hero .body-text { max-width: 46ch; }
/* explicit rows: the mic was auto-placing onto a row of its own BELOW the two
   paragraphs, which is what put 300px of nothing under the text and dropped the
   object to the floor of the band. It shares the text's rows instead. */
body[data-page="reklame"] .web-hero h1 { grid-row: 1; }
body[data-page="reklame"] .web-hero .lead { grid-row: 2; }
body[data-page="reklame"] .web-hero .body-text { grid-row: 3; align-self: start; }
body[data-page="reklame"] .rek-wave { grid-row: 2 / 4; }
@media (max-width: 900px) {
  body[data-page="reklame"] .web-hero h1,
  body[data-page="reklame"] .web-hero .lead,
  body[data-page="reklame"] .web-hero .body-text,
  body[data-page="reklame"] .rek-wave { grid-row: auto; }
}

/* the rail during a mouse drag: kill snap so the fling is not yanked back to a
   snap point mid-decay, and take the text selection cursor off it */
.work-rail.is-dragging { scroll-snap-type: none; cursor: grabbing; user-select: none; }
/* momentum on iOS, and let the compositor own the scroll on touch */
.work-rail { -webkit-overflow-scrolling: touch; overscroll-behavior-x: contain; }

/* ---- v18h: the 01 card's plate --------------------------------------------
   Same negative as everywhere else, cropped to a region no other plate uses,
   graded a touch hotter so it reads as a frame grab rather than as the hero
   photograph repeated. 16:9 because that is what a commercial is delivered in. */
.services .service-media.reel-plate {
  --ar: 16/9; max-width: none; position: relative; overflow: hidden;
  background-color: var(--bg-hi);
  background-image:
    linear-gradient(0deg, rgba(23,19,14,.10), rgba(23,19,14,.10)),
    image-set(url("assets/crowd/crowd-halftone.webp") type("image/webp"),
              url("assets/crowd/crowd-halftone.png")  type("image/png"));
  /* cover, not 210%: zooming in landed on the unlit part of the negative and
     the plate came out as a black rectangle on a beige band. The overlay is
     also down from .22 to .10 — on paper this plate has to read as a printed
     frame, not as a hole. */
  background-size: auto, cover;
  background-position: 0 0, 22% 42%;
  background-repeat: no-repeat;
  filter: contrast(1.04) brightness(1.06);
}

/* ==========================================================================
   v18h — MOTION PASS
   Owner: "site should be more reactive", "items dont have edge glow", "any
   animation that exists isnt smooth enough", "add some text animations".
   Everything below is transform/opacity only, so it composites; nothing here
   animates a layout property, and every rule has a reduced-motion exit.
   ========================================================================== */

/* ---- 1. the service rows answer the pointer ------------------------------
   The card was a link with no state beyond a 1.5% scale on its picture. It now
   lifts as a unit, its numeral ignites, and its rule draws across — three cues
   on one gesture, all of them cheap. */
.services .service { transition: transform 420ms var(--spring); }
.services .service:hover { transform: translateY(-3px); }
.services .service:hover .svc-num { color: var(--on-air-hot); }
.services .service > .svc-num { transition: color 260ms var(--ease-out); }
.services .service--link h3 { position: relative; display: inline-block; }
.services .service--link h3::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: -.12em; height: 2px;
  background: var(--accent); transform: scaleX(0); transform-origin: 0 50%;
  transition: transform 460ms var(--ease-io);
}
.services .service:hover .service--link h3::after,
.services .service--link:focus-visible h3::after { transform: scaleX(1); }

/* ---- 2. text arrives as words, not as a block ----------------------------
   site.js wraps each word of a [data-words] element in a span. The stagger is
   a CSS custom property per word, so the whole effect is one transition and
   the JS never touches style during the animation. */
.w-word { display: inline-block; will-change: transform, opacity; }
@media (prefers-reduced-motion: no-preference) {
  [data-words] .w-word {
    opacity: 0; transform: translateY(0.42em) rotate(0.4deg);
    transition: opacity 520ms var(--ease-out) calc(var(--wi, 0) * 38ms),
                transform 620ms var(--ease-io) calc(var(--wi, 0) * 38ms);
  }
  [data-words].in-view .w-word { opacity: 1; transform: none; }
}

/* ---- 3. list rows step in as they arrive --------------------------------- */
@media (prefers-reduced-motion: no-preference) {
  .rek-step, .offer-pin li, .facts-row {
    transition: opacity 460ms var(--ease-out) calc(var(--ri, 0) * 46ms),
                transform 520ms var(--ease-io) calc(var(--ri, 0) * 46ms);
  }
  .rek-step:not(.in-view) { opacity: 0; transform: translateY(14px); }
  .rek-step.in-view { opacity: 1; transform: none; }
}

/* ---- 4. links draw rather than switch ------------------------------------ */
.nav-pill__links a { position: relative; }
.nav-pill__links a::after {
  content: ""; position: absolute; left: 11px; right: 11px; bottom: 7px; height: 1.5px;
  background: currentColor; opacity: .9;
  transform: scaleX(0); transform-origin: 50% 50%;
  transition: transform 340ms var(--ease-io);
}
.nav-pill__links a:hover::after { transform: scaleX(1); }
.nav-pill__links a[aria-current="page"]::after { transform: scaleX(1); background: var(--on-air); }

/* ---- 5. the story frames answer individually ----------------------------- */
.social-feel .fan .frame-plate-box { transition: transform 520ms var(--spring), filter 380ms linear; }
.social-feel .fan .frame-plate-box:hover { filter: brightness(1.12); }

@media (prefers-reduced-motion: reduce) {
  .services .service, .services .service--link h3::after,
  .nav-pill__links a::after, .social-feel .fan .frame-plate-box,
  .w-word, .rek-step { transition: none !important; transform: none !important; opacity: 1 !important; }
}

/* ==========================================================================
   v18i — LANGUAGE PICKER
   Owner: one translator icon instead of a row of country codes, with the
   languages behind a dropdown. That also buys back the horizontal room the
   fifth nav item needs, which is why it landed before the new page did.
   Native <button>s in a listbox, so keyboard and screen readers work without
   a custom widget; the only JS is open/close, selection and roving focus.
   ========================================================================== */
.lang-picker { position: relative; flex: 0 0 auto; }
.lang-picker__btn {
  display: flex; align-items: center; gap: 7px;
  height: 40px; padding: 0 10px 0 9px; border: 0; border-radius: 999px;
  background: color-mix(in srgb, var(--ink) 8%, transparent);
  color: inherit; cursor: pointer;
  font-family: var(--f-mono); font-size: var(--t-meta);
  letter-spacing: .08em; text-transform: uppercase;
  transition: background-color var(--dur-ui) var(--ease-out);
}
.nav-pill[data-theme="dark"] .lang-picker__btn,
.mobile-sheet .lang-picker__btn,
.site-footer .lang-picker__btn { background: var(--carbon); }
.lang-picker__btn:hover { background: color-mix(in srgb, var(--ink) 14%, transparent); }
.nav-pill[data-theme="dark"] .lang-picker__btn:hover,
.mobile-sheet .lang-picker__btn:hover,
.site-footer .lang-picker__btn:hover { background: color-mix(in srgb, var(--chalk) 16%, transparent); }
.lang-picker__icon { width: 18px; height: 18px; flex: 0 0 auto; opacity: .82; }
.lang-picker__code { font-weight: 700; }
.lang-picker__chev {
  width: 7px; height: 7px; border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor;
  transform: rotate(45deg) translate(-1px, -1px); opacity: .6;
  transition: transform var(--dur-ui) var(--ease-out);
}
.lang-picker__btn[aria-expanded="true"] .lang-picker__chev { transform: rotate(-135deg) translate(-2px, -2px); }

/* The menu used to set a background and let the text colour come from
   whatever it was nested in. Inside the mobile sheet that meant --chalk on
   --paper-hi — 1.03:1, so the options were there but unreadable. Surface and
   ink are one pair now: set them together or not at all. */
.lang-picker__menu {
  position: absolute; top: calc(100% + 8px); right: 0; z-index: 20;
  min-width: 190px; margin: 0; padding: 6px; list-style: none;
  border-radius: 12px; border: 1px solid var(--ink-muted);
  background: var(--paper-hi);
  color: var(--ink);
  box-shadow: 0 18px 40px rgba(23,19,14,.28);
  /* seven languages is ~300px of menu. Cap it against the viewport so the last
     option is always reachable instead of hanging off the bottom edge. */
  max-height: min(60vh, 340px); overflow-y: auto; overscroll-behavior: contain;
  max-width: calc(100vw - 24px);
}
.nav-pill[data-theme="dark"] .lang-picker__menu,
.mobile-sheet .lang-picker__menu,
.site-footer .lang-picker__menu {
  background: var(--carbon); border-color: var(--ash-dim); color: var(--chalk);
  --badge-on: #D76157;                                   /* 4.66:1 on --carbon */
}
/* it opens upward in the footer — there is no room below it there */
.site-footer .lang-picker__menu { top: auto; bottom: calc(100% + 8px); }
/* Same problem in the mobile sheet, and going from three languages to seven is
   what exposed it: a 298px menu opening DOWNWARD from a button already two
   thirds of the way down the sheet ran 43-124px off the bottom on a 360x640,
   320x568 and 414x736. It opens upward here too, and the cap is expressed
   against the viewport so it actually engages on a short screen instead of
   sitting above the menu's natural height and never applying. */
.mobile-sheet .lang-picker__menu {
  top: auto; bottom: calc(100% + 8px);
  max-height: min(46vh, 300px);
  left: 0; right: auto; min-width: min(260px, calc(100vw - 40px));
}
.lang-picker__menu[hidden] { display: none; }
.lang-picker__menu [role="option"] {
  display: flex; align-items: baseline; gap: 10px; width: 100%;
  padding: 9px 10px; border: 0; border-radius: 8px; background: transparent;
  color: inherit; cursor: pointer; text-align: left;
  text-decoration: none;                 /* they are links now, for crawlers and for no-JS */
  font-family: var(--f-text); font-size: 14px;
  transition: background-color 140ms linear;
}
.lang-picker__menu [role="option"] span {
  font-family: var(--f-mono); font-size: var(--t-stamp); letter-spacing: .1em;
  opacity: .68; min-width: 30px;
}
.lang-picker__menu [role="option"]:hover { background: color-mix(in srgb, var(--ink) 9%, transparent); }
.nav-pill[data-theme="dark"] .lang-picker__menu [role="option"]:hover,
.mobile-sheet .lang-picker__menu [role="option"]:hover,
.site-footer .lang-picker__menu [role="option"]:hover { background: color-mix(in srgb, var(--chalk) 12%, transparent); }
.lang-picker__menu [role="option"][aria-selected="true"] { font-weight: 700; }
/* --on-air is a lamp colour and says so. --on-air-hot carries labels, but its
   4.65:1 is quoted against --void and this menu is --carbon, which is lighter —
   it lands at 4.47:1, three hundredths under AA. And the menu has BOTH a light
   and a dark surface, so one red cannot serve it: the same accent that clears
   4.66:1 on carbon is 2.82:1 on paper-hi. Two, on the same hue, each measured
   against the surface it actually sits on. */
.lang-picker__menu { --badge-on: #B03931; }              /* 4.65:1 on --paper-hi */
.lang-picker__menu [role="option"][aria-selected="true"] span { color: var(--badge-on); opacity: 1; }
/* same 860px as the nav collapse above — the two must move together */
@media (max-width: 860px) {
  .nav-pill .lang-picker { display: none; }   /* it lives in the mobile sheet */
}

/* ==========================================================================
   v18i — APLIKACIJE (dashboards and business tools)
   The evidence on this page is screenshots of software that actually runs, so
   the plates are plain framed captures rather than the halftone treatment the
   rest of the site prints. A dashboard photographed through a dot screen would
   be unreadable, and unreadable is the opposite of the point.
   ========================================================================== */
/* One frame for every capture on the site.
   There used to be three: browser chrome around the veil shot, a dark vignetted
   slab around the lukasz shot, and this plain border around the app shots. Three
   treatments for four pictures reads as three different people building one
   site. The plain border is the one that survived — it frames the evidence
   without competing with it — so it is the only one now, and every capture is a
   true 16:9 so no frame has to crop to agree with its neighbour. */
.shot-frame {
  --ar: 16/9; overflow: hidden; position: relative;
  border: 1px solid var(--border); border-radius: 3px;
  background: #0A0A0B;
  box-shadow: 0 26px 52px rgba(23,19,14,.24);
  align-self: center;
}
.shot-frame picture, .shot-frame img {
  display: block; width: 100%; height: 100%;
  object-fit: cover; object-position: 50% 0;
  opacity: .96;
  transition: opacity var(--dur-ui) var(--ease-out);
}
.shot-frame:hover img { opacity: 1; }
/* the same dot screen the photographic plates are printed with, so a screenshot
   belongs to this page's material instead of sitting on top of it */
.shot-frame::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  background-image: radial-gradient(circle, rgba(237,229,214,.30) .7px, transparent 1.1px);
  background-size: 4px 4px; mix-blend-mode: overlay; opacity: .22;
}
@media (prefers-reduced-motion: reduce) { .shot-frame img { transition: none; } }

/* MOTION 2 of 3 — the print comes up in the developer.
   A capture on this site is a print, so it arrives the way a print does: flat
   and low-contrast under a wash, resolving as it clears the tray. The wash is a
   moving highlight, not a fade-in — a plain opacity ramp on every element is
   the single most generic thing a page can do, and this band already earns a
   photographic move. Runs off the view timeline, so it is tied to where the
   frame is on screen rather than to a fixed clock. */
@keyframes print-develop {
  from { filter: contrast(.55) brightness(1.22) saturate(.6); }
  60%  { filter: contrast(1) brightness(1) saturate(1); }
  to   { filter: contrast(1) brightness(1) saturate(1); }
}
@keyframes print-wash {
  from { transform: translateX(-120%); opacity: .5; }
  70%  { opacity: .28; }
  to   { transform: translateX(160%); opacity: 0; }
}
.shot-frame::before {
  content: ""; position: absolute; inset: -20% -60%; z-index: 2; pointer-events: none;
  background: linear-gradient(100deg,
    transparent 0%, rgba(237,229,214,.05) 38%, rgba(237,229,214,.16) 50%,
    rgba(237,229,214,.05) 62%, transparent 100%);
  opacity: 0;
}
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .shot-frame {
      animation: print-develop linear both;
      animation-timeline: view();
      animation-range: entry 5% cover 42%;
    }
    .shot-frame::before {
      animation: print-wash linear both;
      animation-timeline: view();
      animation-range: entry 8% cover 46%;
    }
  }
}
.web-hero .shot-frame { margin-top: clamp(18px, 2.6vh, 34px); }
body[data-page="aplikacije"] .web-hero h1 { grid-row: 1; }
body[data-page="aplikacije"] .web-hero .lead { grid-row: 2; align-self: start; }
body[data-page="aplikacije"] .web-hero .body-text { grid-row: 3; align-self: start; }
body[data-page="aplikacije"] .web-hero .shot-frame { grid-row: 2 / 5; }
/* row 4 exists only to take the shot's leftover height; without it the slack
   lands in row 2 and opens a gap between the lead and the sentence under it */
body[data-page="aplikacije"] .web-hero .grid { grid-template-rows: auto auto auto 1fr; }

.app-proof__fig { margin: 0; align-self: center; }
.app-proof__cap {
  margin-top: 10px; text-align: right; color: var(--fg-small-muted);
  font-size: var(--t-stamp); letter-spacing: .12em;
}
.app-proof h2 { margin-bottom: clamp(14px, 2.2vh, 24px); }
.app-proof .body-text { color: var(--fg-body); }

@media (max-width: 900px) {
  body[data-page="aplikacije"] .web-hero h1,
  body[data-page="aplikacije"] .web-hero .lead,
  body[data-page="aplikacije"] .web-hero .body-text,
  body[data-page="aplikacije"] .web-hero .shot-frame { grid-row: auto; }
  .web-hero .shot-frame, .app-proof__fig { grid-column: 1 / -1; margin-top: 22px; }
}

/* the radio band: two columns of statement over a four-row spec sheet that
   runs the full measure, so it reads as the oldest and broadest thing here
   rather than as a fourth equal card */
.rek-radio h2 { align-self: start; }
.rek-radio .body-text { color: var(--ash); align-self: start; }
.rek-radio__list { margin-top: clamp(22px, 3.4vh, 44px); }
.rek-radio__list ul { columns: 2; column-gap: clamp(28px, 4vw, 72px); }
.rek-radio__list li { break-inside: avoid; opacity: 1; transform: none; color: var(--chalk); }
.rek-radio__list li::before { color: var(--on-air-hot); }
.rek-radio__list li + li::after { transform: scaleX(1); }
@media (max-width: 760px) {
  .rek-radio__list ul { columns: 1; }
  .rek-radio h2, .rek-radio .body-text { grid-column: 1 / -1; }
}

/* ==========================================================================
   PRAVNO — the legal page
   Mandatory business identifiers, the privacy position and the terms. Set as a
   record rather than as prose: a definition list reads faster than paragraphs
   when someone is looking for one number, and it survives nine languages
   without any of them fighting the column.
   ========================================================================== */
.legal h1 { margin-bottom: 6px; }
.legal .lead { margin-bottom: 40px; }
.legal-block { margin-bottom: 44px; }
.legal-block h2 {
  font-size: var(--t-h3); margin-bottom: 14px;
  padding-bottom: 8px; border-bottom: 1px solid var(--border);
}
.legal-block .body-text + .body-text { margin-top: 12px; }
.legal-dl { display: grid; grid-template-columns: minmax(9rem, auto) 1fr; gap: 10px 22px; margin: 0; }
.legal-dl dt {
  font-family: var(--f-mono); font-size: var(--t-meta);
  letter-spacing: .06em; text-transform: uppercase; color: var(--fg-small-muted);
}
.legal-dl dd { margin: 0; color: var(--fg); overflow-wrap: break-word; }
/* an identifier we do not have yet is shown as missing, not faked */
.legal-dl dd.is-pending { color: var(--fg-small-muted); font-style: italic; }
.legal-list { margin: 14px 0 0; padding-left: 1.1em; display: flex; flex-direction: column; gap: 10px; }
.legal-list li { color: var(--fg); }
.site-footer .legal-link { display: inline-block; margin-top: 10px; }
@media (max-width: 900px) {
  .legal-block { grid-column: 1 / -1; }
  .legal-dl { grid-template-columns: 1fr; gap: 4px 0; }
  .legal-dl dd { margin-bottom: 12px; }
}

/* 03 on the home page carries the handset too. It is the same render as the
   social page, mirrored and given a different resting angle, because the object
   belongs to this service and a second 9-minute render would buy nothing a
   transform cannot. Sized to the card, not to the column. */
.services .service-media.phone-object {
  aspect-ratio: auto; --ar: auto; max-height: none;
  display: flex; align-items: center; justify-content: center;
  background: none; border: 0; overflow: visible;
  perspective: 1100px;
}
/* NOT mirrored. scaleX(-1) reversed the wordmark on the screen — the render is
   a photograph of a screen, not a symmetrical object. The two placements are
   told apart by angle and size instead. */
.phone-render--flip {
  display: block; width: min(15vw, 172px); height: auto;
  /* tight and close. a wide soft shadow under an almost-rectangular silhouette
     is a grey box, not a shadow, and on --paper it was clearly visible. */
  filter: drop-shadow(0 8px 12px rgba(23,19,14,.26));
  transform-origin: 50% 60%;
}
@keyframes phone-turn-flip {
  from { transform: rotateY(16deg) rotateZ(1.4deg) translateY(16px); }
  to   { transform: rotateY(-6deg) rotateZ(-1.1deg) translateY(-14px); }
}
@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .phone-3d--alt {
      animation: phone-turn-flip linear both;
      animation-timeline: view();
      /* the full pass. a narrow range crams the whole turn into a short stretch
         of scroll, so every wheel notch moves it a long way — measured at 10.9
         units per step against 2.9 for the handset that uses the full span. an
         ambient turn wants the most travel it can get, not the least. */
      animation-range: entry 0% exit 100%;
    }
  }
}
@supports not (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .phone-3d--alt {
      transform:
        rotateY(calc(16deg - var(--vp, .5) * 22deg))
        rotateZ(calc(1.4deg - var(--vp, .5) * 2.5deg))
        translateY(calc(16px - var(--vp, .5) * 30px));
    }
  }
}
@media (max-width: 900px) {
  .phone-render--flip { width: min(42vw, 190px); }
  .services .service-media.phone-object { min-height: 0; padding-block: 8px; }
}

/* ---- the glass ------------------------------------------------------------
   The handset is turned 19 degrees, so anything laid on its screen has to be
   turned with it — a flat rotation sits ON the picture rather than IN it, which
   is what he saw. Blender projected the screen plane's four corners
   (tools/render/SCREEN-QUAD.json) and the matrix below is the exact homography
   from a 407x940 design canvas onto those corners. Text lays out normally in
   that canvas and the browser warps it into the plane, so the caption shares
   the phone's perspective instead of approximating it.

   The canvas is in the asset's own pixel space, so it is scaled back down by
   --phone-scale, which site.js keeps equal to rendered width / 497. */
.phone-glass {
  position: absolute; left: 0; top: 0;
  width: 407px; height: 940px;
  transform-origin: 0 0;
  transform: scale(var(--phone-scale, 0.288)) matrix3d(1.15239308,-0.05893212,0.00000000,-0.00014141,0.00615472,1.30719486,0.00000000,0.00002090,0.00000000,0.00000000,1.00000000,0.00000000,28.93932905,87.51748751,0.00000000,1.00000000);
  border-radius: 34px;
  overflow: hidden;
  display: flex; align-items: center; justify-content: center;
  padding: 0 46px;
  pointer-events: none;
  /* an OLED is not flat black: it is near-black with the panel's own slight
     lift, and it catches the room. The render supplies a dark panel; this is
     the glass on top of it. */
  /* #05060A was a cold black, and it was BLACKER than the render's own bezel
     (measured rgb 16-34 at the screen edge). That inverted the contrast: the
     bezel stopped reading as part of the phone and started reading as a warm
     strip down the right-hand side. An OLED in a warm room is not cold black —
     it picks the room up. Warm near-black, and the last few percent of the
     panel falls off into the bezel instead of meeting it at a hard line. */
  background:
    radial-gradient(125% 82% at 50% 50%,
      rgba(0,0,0,0) 62%, rgba(0,0,0,.55) 92%, rgba(0,0,0,.8) 100%),
    linear-gradient(148deg,
      rgba(237,229,214,.05) 0%, rgba(237,229,214,.012) 26%,
      rgba(0,0,0,0) 54%, rgba(0,0,0,.22) 100%),
    radial-gradient(120% 78% at 22% 8%, rgba(237,229,214,.06) 0%, transparent 58%),
    #0B0907;
}
.phone-cap {
  display: flex; flex-direction: column; align-items: center; gap: 22px;
  text-align: center;
}
.phone-cap i {
  display: block; width: 46px; height: 5px; background: var(--on-air); border-radius: 3px;
}
.phone-cap b {
  font-family: var(--f-display); font-weight: 800;
  font-size: 74px; line-height: 1.02; letter-spacing: -0.02em;
  color: var(--chalk); text-transform: lowercase; text-wrap: balance;
}

/* ==========================================================================
   THE LAPTOP, DRIVEN BY SCROLL INSTEAD OF BY JAVASCRIPT
   Owner, twice: "the laptop animation still looks like a badly keyframed
   overlay animation and low end hardware cant handle it well." Both halves have
   the same cause. initLaptopStage() read getBoundingClientRect every frame,
   wrote three custom properties and toggled a class on seven list items, then
   kept ticking after the wheel stopped because the value was damped. On a weak
   machine that is a main-thread job competing with the paint it is driving —
   which is exactly what "badly keyframed" looks like from the outside.

   Registering the three properties lets the browser interpolate them itself, so
   the same motion runs off a scroll timeline with no script in the loop. The JS
   stays for browsers without scroll-driven animations and stands down where
   they exist, the same arrangement the fader rule uses.
   ========================================================================== */
@property --crt   { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --flare { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --push  { syntax: "<number>"; inherits: true; initial-value: 1; }

@supports (animation-timeline: view()) and (animation-range: contain 0%) {
  @media (prefers-reduced-motion: no-preference) {
    /* the band is taller than the viewport, so `contain` is the span where it
       actually covers the screen — the same stretch the old scrub measured */
    .laptop-stage-section { view-timeline: --laptop block; }

    /* a CRT does not fade up, it strikes and the raster opens off the line.

       These stops used to be the ones the old JS scrub used — cold until 5%,
       standing at 28% — and inherited its assumption that the range was the
       whole section. It is not. `contain` on a band taller than the viewport
       spans (height - viewport), which here is 1420 - 900 = 520px, so the
       entire strike was happening inside about 120px of scrolling and read as a
       snap. Measured: --crt went 0 -> 1 between two adjacent samples.

       The wake now occupies two thirds of the range instead of a quarter, so
       the same motion is spread over roughly 340px. Nothing got longer — the
       tail from 28% to 100% was drifting --push by 0.026 and doing nothing
       else, which is a lot of scroll spent on almost nothing. */
    @keyframes laptop-wake {
      0%    { --crt: 0;    --flare: 0;    --push: 1; }
      12%   { --crt: 0;    --flare: 0;    --push: 1.004; }   /* the beat before it strikes */
      34%   { --crt: 0.5;  --flare: 0.55; --push: 1.012; }   /* half open, flare at its peak */
      65%   { --crt: 1;    --flare: 0;    --push: 1.024; }   /* picture stands */
      100%  { --crt: 1;    --flare: 0;    --push: 1.04; }    /* settles forward */
    }
    .laptop-stage .laptop-seq {
      animation: laptop-wake linear both;
      animation-timeline: --laptop;
      animation-range: contain 0% contain 100%;
    }

    /* the lamp was an attribute the script wrote once the raster passed 0.55.
       Derived from --crt instead, so nothing has to touch the DOM for it. */
    .laptop-seq .lamp {
      opacity: clamp(0, (var(--crt) - 0.55) * 24, 1);
      box-shadow: 0 0 12px rgba(228, 72, 58, clamp(0, (var(--crt) - 0.55) * 24, 1));
      transition: none;
    }

    /* the list arrives while the raster is still opening, one line at a time.
       Written out rather than calc'd off an index: animation-range does not
       take a custom property everywhere yet, and seven literals cannot drift. */
    @keyframes pin-reveal {
      from { opacity: 0; transform: translateX(-14px); }
      to   { opacity: 1; transform: translateX(0); }
    }
    .laptop-stage .offer-pin li {
      animation: pin-reveal cubic-bezier(.22,1,.36,1) both;
      animation-timeline: --laptop;
    }
    .laptop-stage .offer-pin li:nth-child(1) { animation-range: contain 22% contain 34%; }
    .laptop-stage .offer-pin li:nth-child(2) { animation-range: contain 30% contain 42%; }
    .laptop-stage .offer-pin li:nth-child(3) { animation-range: contain 38% contain 50%; }
    .laptop-stage .offer-pin li:nth-child(4) { animation-range: contain 46% contain 58%; }
    .laptop-stage .offer-pin li:nth-child(5) { animation-range: contain 54% contain 66%; }
    .laptop-stage .offer-pin li:nth-child(6) { animation-range: contain 62% contain 74%; }
    .laptop-stage .offer-pin li:nth-child(7) { animation-range: contain 70% contain 82%; }
  }
}

/* ---- the fan's captions, in the DOM where they can be translated -----------
   These were burned into story-a/b/c.webp as pixels. They looked right and
   could never change language — the owner switched to another language and the
   words stayed Slovenian, because they were part of the photograph. Same
   mistake the phone screen deliberately avoids. Only the scrim is baked now; a
   gradient has no language. */
.social-feel .fan .frame-plate-box { display: block; }
.fan-rail {
  position: absolute; left: 5.5%; right: 5.5%; top: 3.2%;
  display: flex; gap: 3.5%; pointer-events: none;
}
.fan-rail i {
  flex: 1 1 0; height: 3px; border-radius: 2px;
  background: rgba(237,229,214,.28);
}
.fan-rail i:first-child { background: var(--chalk); }
/* the middle segment is the one running, so it is part-filled */
.fan-rail i:nth-child(2) {
  background: linear-gradient(to right, var(--chalk) 0 var(--fan-progress, 45%),
                                        rgba(237,229,214,.28) var(--fan-progress, 45%) 100%);
}
.fp1 { --fan-progress: 12%; }
.fp2 { --fan-progress: 46%; }
.fp3 { --fan-progress: 78%; }

.fan-kind {
  position: absolute; left: 5.5%; top: 8.5%;
  display: flex; align-items: center; gap: 7px;
  font-family: var(--f-mono); font-size: 10px; letter-spacing: .13em;
  text-transform: uppercase; color: rgba(237,229,214,.92);
}
.fan-kind b {
  width: 7px; height: 7px; border-radius: 50%; background: var(--on-air-hot);
  flex: 0 0 auto;
}
.fan-cap {
  position: absolute; left: 5.5%; right: 5.5%; bottom: 12.5%;
  padding-left: 11px; border-left: 3px solid var(--on-air-hot);
  font-family: var(--f-display); font-weight: 700;
  font-size: clamp(15px, 1.5vw, 22px); line-height: 1.05;
  color: var(--chalk);
  /* a caption can be one word in Slovenian and four in German; it wraps rather
     than running off the plate it is printed on */
  overflow-wrap: anywhere; hyphens: auto;
}
.fan-mark {
  position: absolute; left: 5.5%; bottom: 6%;
  font-family: var(--f-mono); font-size: 9.5px; letter-spacing: .16em;
  color: rgba(237,229,214,.62);
}
@media (max-width: 900px) {
  .fan-kind { font-size: 9px; }
  .fan-mark { font-size: 8.5px; }
}

/* Two bands of the same colour do not need a fade between them — a wash there
   runs the page to the opposite tone and back, which is what read as a
   gradient pointing the wrong way. They get a rule instead: one hairline at the
   accent, fading out to either side, which marks the join without inventing a
   colour change that is not happening. */
.seam--rule { height: clamp(38px, 5vh, 64px); display: grid; place-items: center; }
.seam--rule::after {
  content: ""; width: min(100%, calc(1180px + var(--gap))); height: 1px;
  background: linear-gradient(to right,
    transparent 0%, var(--edge) 18%, var(--edge) 82%, transparent 100%);
  opacity: .55;
}

/* the page behind an open mobile sheet used to scroll with the wheel — measured
   0 to 1440 while the sheet was up. Fixing the body holds the position; site.js
   records and restores it so closing does not jump you to the top. */
body.sheet-open { position: fixed; left: 0; right: 0; width: 100%; overflow: hidden; }

/* ==========================================================================
   MOTION ON A PHONE
   Measured, not guessed. The same animation-range behaves differently on a
   phone because the document is far taller relative to the viewport, so a
   range like `entry 12% exit 88%` compresses the whole sweep into much less
   scroll. phone-turn moved 4.33 degrees per scroll step at 390px against 1.59
   at 1440 — nearly three times coarser — and touch momentum arrives in bigger
   increments than a wheel, which is what "fast, choppy, not made for phone"
   actually was.

   Two changes, both toward the same end: give each animation the widest range
   the element has, so the sweep is spread over as much scroll as exists, and
   cut the amplitude so whatever stepping remains is smaller than the eye
   catches. Nothing here is a duration — none of this runs on a clock.
   ========================================================================== */
@media (max-width: 900px) and (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {

    /* The animation lives on .phone-3d, not on the image inside it. Naming the
       image as well ran BOTH and the sweep compounded: 19.4 degrees became 24
       and the per-step jump doubled to 8.1. One element, one animation.
       Each handset keeps its own direction — the index one is turned the other
       way on purpose so the two pages do not show the same shot. */
    @keyframes phone-turn-mobile {
      from { transform: rotateY(-7deg) rotateZ(-0.6deg) translateY(8px); }
      to   { transform: rotateY(6deg)  rotateZ(0.7deg)  translateY(-9px); }
    }
    @keyframes phone-turn-flip-mobile {
      from { transform: rotateY(8deg)  rotateZ(0.7deg)  translateY(9px); }
      to   { transform: rotateY(-4deg) rotateZ(-0.6deg) translateY(-8px); }
    }
    .social-offer .phone-3d {
      animation-name: phone-turn-mobile;
      animation-range: entry 0% exit 100%;
    }
    .phone-3d--alt {
      animation-name: phone-turn-flip-mobile;
      animation-range: entry 0% exit 100%;
    }

    /* the print develops over its whole approach rather than the first third,
       so the contrast ramp is gradual instead of arriving in three steps */
    .shot-frame {
      animation-range: entry 0% cover 70%;
    }
    .shot-frame::before {
      animation-range: entry 0% cover 78%;
    }

    /* the rule draws across the full pass; on a phone the band it belongs to
       is most of the screen, so a 62% range finished before it was noticed */
    .fader-line { animation-range: entry 0% cover 90%; }

    /* the laptop arrives more gently and over more travel — it is the largest
       object on the page at this width */
    @keyframes laptop-arrive-mobile {
      from { transform: translateY(14px) scale(.985); opacity: .7; }
      to   { transform: translateY(0)    scale(1);    opacity: 1; }
    }
    .laptop-seq {
      animation-name: laptop-arrive-mobile;
      animation-range: entry 0% cover 55%;
    }
  }
}

/* ==========================================================================
   ARRIVAL
   The boot screen hands over to a page that was simply already there. Nothing
   above the fold can use a scroll timeline — you have not scrolled yet — so
   this is the one place a clock is the right driver.

   Two things only. The plate behind the headline keeps drifting, so the first
   thing you see is not a still photograph; and the headline block arrives in
   the order you would read it. No fade-up on everything: that is the tell.
   ========================================================================== */
@media (prefers-reduced-motion: no-preference) {

  /* A very slow push on the crowd. 24s and 3.5% — under the threshold where a
     scale reads as a zoom, which is the point: it should feel alive without
     ever being caught moving. Alternating, so it never snaps back to the start. */
  @keyframes plate-drift {
    from { transform: scale(1) translate3d(0, 0, 0); }
    to   { transform: scale(1.035) translate3d(-0.6%, -0.8%, 0); }
  }
  .hero-crowd-bleed,
  .social-hero .crowd-plate {
    animation: plate-drift 24s cubic-bezier(.45,0,.55,1) infinite alternate;
    will-change: transform;
  }

  /* The headline block, in reading order. Each line clears its own mask rather
     than fading, so the motion belongs to type being set instead of a generic
     opacity ramp. */
  @keyframes line-set {
    from { opacity: 0; transform: translate3d(0, 14px, 0); clip-path: inset(0 0 100% 0); }
    60%  { opacity: 1; }
    to   { opacity: 1; transform: translate3d(0, 0, 0);    clip-path: inset(0 0 -10% 0); }
  }
  .hero-content .hero-mono,
  .hero-content h1,
  .hero-content .lead,
  .hero-content .btn,
  .web-hero h1,
  .web-hero .lead,
  .social-hero h1,
  .social-hero .lead {
    animation: line-set 900ms cubic-bezier(.22,1,.36,1) both;
  }
  /* the boot screen holds for 3.2s; the type starts as it clears, not under it */
  .hero-content .hero-mono { animation-delay: 3.05s; }
  .hero-content h1         { animation-delay: 3.18s; }
  .hero-content .lead      { animation-delay: 3.34s; }
  .hero-content .btn       { animation-delay: 3.46s; }
  .web-hero h1, .social-hero h1       { animation-delay: 3.10s; }
  .web-hero .lead, .social-hero .lead { animation-delay: 3.26s; }

  /* the pill drops in rather than being present before the page is */
  @keyframes pill-drop {
    from { opacity: 0; transform: translate3d(0, -14px, 0); }
    to   { opacity: 1; transform: translate3d(0, 0, 0); }
  }
  .nav-pill { animation: pill-drop 620ms cubic-bezier(.22,1,.36,1) both 2.95s; }
}

/* ---- is anyone in the studio ----------------------------------------------
   Nine languages means someone may be reading a Slovenian phone number from a
   timezone where ringing it now would be rude. The lamp is the site's own ON
   AIR motif rather than new vocabulary, and the claim is deliberately soft:
   the time is a fact, "usually reachable" is not a promise. */
.onair {
  display: flex; align-items: center; gap: 9px; flex-wrap: wrap;
  margin: 10px 0 0; font-family: var(--f-mono); font-size: var(--t-stamp);
  letter-spacing: .1em; text-transform: uppercase; color: var(--fg-small-muted);
}
.onair__lamp {
  width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto;
  background: var(--ash-dim);
  box-shadow: inset 0 0 0 1px rgba(0,0,0,.35);
}
.onair[data-live="1"] .onair__lamp {
  background: var(--on-air-hot);
  box-shadow: 0 0 9px var(--on-air-hot), inset 0 0 0 1px rgba(0,0,0,.25);
}
.onair[data-live="1"] .onair__state { color: var(--fg-body); }
.onair__time { opacity: .72; letter-spacing: .08em; }
.onair__time b { font-weight: 600; font-variant-numeric: tabular-nums; }
/* a lamp that is lit does not sit perfectly still; two-step, like the tally */
@media (prefers-reduced-motion: no-preference) {
  .onair[data-live="1"] .onair__lamp { animation: tally 3.2s steps(2, jump-none) infinite; }
}

/* ---- states on the things you can actually press ---------------------------
   An audit of every interactive element found ten whose hover changed nothing
   at all — the footer's mail, phone and facebook links, and the nav inside the
   mobile sheet. A link that does not answer the pointer is the "hover states
   that do nothing" tell, and on the contact details it is the worst place for
   it: those are the three things a visitor is most likely to reach for.

   The answer is a rule that draws under the word, not a colour change — the
   site already uses a drawn rule as its accent everywhere else, so this is the
   same vocabulary rather than a new one. */
.site-footer .foot-col a,
.mobile-sheet nav a {
  position: relative;
  /* the links are display:block, so a rule at left:0/right:0 would run the full
     column — a 330px underline beneath the word "facebook". fit-content pins
     the box to the text it contains, which is what the rule should measure. */
  width: fit-content; max-width: 100%;
  transition: color var(--dur-ui) var(--ease-out);
}
.site-footer .foot-col a::after,
.mobile-sheet nav a::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: -3px; height: 1px;
  background: var(--on-air-hot);
  transform: scaleX(0); transform-origin: left;
  transition: transform 260ms var(--ease-out);
}
.site-footer .foot-col a:hover,
.site-footer .foot-col a:focus-visible,
.mobile-sheet nav a:hover,
.mobile-sheet nav a:focus-visible { color: var(--fg); }
.site-footer .foot-col a:hover::after,
.site-footer .foot-col a:focus-visible::after,
.mobile-sheet nav a:hover::after,
.mobile-sheet nav a:focus-visible::after { transform: scaleX(1); }
@media (prefers-reduced-motion: reduce) {
  .site-footer .foot-col a::after,
  .mobile-sheet nav a::after { transition: none; }
}

/* ==========================================================================
   PRINT
   The legal page carries the registered name, address, matična and davčna
   številka — the details a client saves as a PDF for their records, and the
   ones an inspector asks for. Printed as-authored it came out chalk-on-#17130E:
   an unreadable black page that empties a cartridge. There was no print
   stylesheet at all.

   Ink on paper, chrome gone, and every link's address written out — a printed
   page cannot be clicked, so a bare "facebook" is useless on it.
   ========================================================================== */
@media print {
  /* the palette inverts wholesale: every token the page reads is redefined
     once here rather than overriding a hundred rules individually */
  :root,
  section[data-theme="dark"], section[data-theme="paper"], section[data-theme="pitch"],
  .site-footer, body {
    --void: #fff; --carbon: #fff; --pitch: #fff; --paper: #fff; --paper-hi: #fff;
    --bg: #fff; --bg-hi: #fff;
    --chalk: #000; --ink: #000;
    --fg: #000; --fg-body: #1a1a1a; --fg-muted: #444; --fg-small-muted: #333;
    --ash: #333; --ash-dim: #666; --edge: #bbb; --border: #bbb; --rule: #bbb;
    --on-air: #000; --on-air-hot: #000; --accent: #000; --accent-ink: #000;
    background: #fff !important;
    color: #000 !important;
  }
  body { font-size: 11pt; line-height: 1.45; }

  /* navigation, decoration and anything that only makes sense on a screen */
  .nav-pill, .mobile-sheet, .boot-screen, .scroll-progress, .skip-link,
  .footer-dial, .lang-picker, .sound-toggle, .menu-toggle, .cursor-ring,
  .hero-canvas-wrap, .hero-crowd-bleed, .crowd-plate, .crop-marks,
  .seam, .work-rail-wrap, .fan, .phone-wrap, .phone-object, .laptop-stage,
  .object-plate, .onair { display: none !important; }

  /* pictures that are evidence stay; pictures that are atmosphere go */
  .shot-frame { break-inside: avoid; border: 1px solid #bbb; }
  img { max-width: 100% !important; height: auto !important; filter: none !important; }

  /* a printed page cannot be clicked, so the address has to be on it */
  a[href^="http"]::after  { content: " (" attr(href) ")"; font-size: 9pt; color: #555; }
  a[href^="mailto:"]::after { content: " (" attr(data-print-mail) ")"; }
  /* attr(href) on a tel: link prints the scheme too — "(tel:+386...)".
     the number is written out on the element instead. */
  a[href^="tel:"]::after    { content: " (" attr(data-print-tel) ")"; font-size: 9pt; color: #555; }
  a[href^="#"]::after, a[href$=".html"]::after { content: ""; }

  h1, h2, h3 { break-after: avoid; color: #000 !important; }
  p, li, dd, dt { orphans: 3; widows: 3; }
  .legal-block, .legal-dl { break-inside: avoid; }
  section, .section { padding-block: 12pt !important; overflow: visible !important; }

  /* nothing animates on paper */
  *, *::before, *::after {
    animation: none !important; transition: none !important;
    transform: none !important; box-shadow: none !important;
  }
  @page { margin: 16mm 14mm; }
}

/* Windows high-contrast and any forced-colour mode: let the OS win, but keep
   the things that carry meaning through colour alone from vanishing. */
@media (forced-colors: active) {
  .onair__lamp, .live-dot, .nav-pill__bars span { forced-color-adjust: none; }
  .shot-frame, .frame-box { border: 1px solid CanvasText; }
  .btn, .lang-picker__btn, .menu-toggle { border: 1px solid ButtonText; }
}
