/* ==========================================================================
   CG LABS, main.css
   Design tokens extracted verbatim from the Framer project "CG LABS".
   Vanilla CSS, no framework, no build step.
   ========================================================================== */

/* ------------------------------------------------------------------ fonts */

/* Astro Armada: display face, logotype only. Licensed file supplied by CG Labs. */
@font-face {
  font-family: "Astro Armada";
  src: url("../fonts/astroarmada.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/* Inter is a variable font: one 48KB file covers every weight the site uses.
   Google Fonts serves the same file per weight, so requesting 400/500/600/700
   separately would ship the identical payload four times. */
@font-face {
  font-family: "Inter";
  src: url("../fonts/inter-var.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Bebas Neue";
  src: url("../fonts/bebasneue-400.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap;
}

/* Space Grotesk (footer headings) and Montserrat (world-card captions) used to
   be declared here. Both now resolve to Inter, see the font tokens below for
   why. Their @font-face blocks are removed rather than left in place: an unused
   @font-face costs nothing on its own, but leaving it invites the family back
   into a stylesheet by accident. The woff2 files are still on disk, unreferenced
   (they fall under the orphaned-asset cleanup in CLAUDE.md), so restoring either
   face is a matter of putting its block back. */

/* Outfit is the Gen World hero face (and the 3D World subtitle). Variable,
   one file covers the 500 and 700 weights both pages use. */
@font-face {
  font-family: "Outfit";
  src: url("../fonts/outfit-var.woff2") format("woff2");
  font-weight: 100 900; font-style: normal; font-display: swap;
}

/* ----------------------------------------------------------------- tokens */

:root {
  /* Brand colors, verbatim from Framer color styles */
  --white: #ffffff;
  --black: #0c0c0c;
  --primary: #f9452d;
  --gray: #f5f5f5;

  /* The brand red is a 3.55:1 surface for white text, so every solid red
     button shipped below the 4.5:1 AA floor (the footer CTA's own #e63329 was
     4.31). --primary stays exactly as the brand defines it and is still used
     wherever contrast is not at stake: decorative fills, focus rings, the
     pager dots, and accent text at display sizes, which only needs 3:1 and
     clears it. --primary-cta is the same hue pulled down to 4.83:1 with white,
     and is used only where white text actually sits on the red. */
  --primary-cta: #d6331c;

  /* Derived ink scale */
  --ink: #0c0c0c;
  --ink-2: rgba(12, 12, 12, 0.72);
  --ink-mut: rgba(12, 12, 12, 0.6);
  /* 0.45 was 3.13:1 on white, under the 4.5:1 AA floor, and this token
     carries most of the small muted UI text on light surfaces (booking
     widget, eyebrow labels). 0.58 is the exact floor; 0.6 leaves margin. */
  --ink-faint: rgba(12, 12, 12, 0.6);
  --line: rgba(12, 12, 12, 0.12);

  /* On-dark ink scale */
  --dk-ink: #ffffff;
  --dk-ink-2: rgba(255, 255, 255, 0.7);
  --dk-ink-mut: rgba(255, 255, 255, 0.62);
  --dk-line: rgba(255, 255, 255, 0.14);

  /* Surfaces */
  --bg: #ffffff;
  --bg-alt: #f5f5f5;
  --bg-dark: #0c0c0c;

  /* Type */
  /* Four families, down from six. The Framer export shipped Space Grotesk
     (footer headings, founder name) and Montserrat (world-card captions)
     alongside Inter. Neither carried a brand moment: they appeared in small
     uppercase labels and footer headings where the difference from Inter is
     not perceptible as a decision, only as a faint inconsistency, and they
     cost 57KB of font payload between them. Both now resolve to Inter.
     --font-heading is kept as a role token rather than search-replaced at its
     six call sites, so "this is a heading" stays expressed in the CSS and the
     family behind it can change again in one place.
     Astro Armada (logotype), Bebas Neue ("LET'S TALK.", world titles) and
     Outfit (Gen World hero) stay: each is a deliberate display face doing
     work Inter cannot. */
  --font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --font-display: "Astro Armada", "Inter", sans-serif;
  --font-bebas: "Bebas Neue", "Inter", sans-serif;
  --font-heading: var(--font-sans);
  --font-outfit: "Outfit", "Inter", sans-serif;

  /* Fluid type scale, matches the Framer text styles at their breakpoints.
     Framer's desktop h1 is 116px; it steps down to ~44px on mobile. */
  --fs-hero: clamp(2.75rem, 1.2rem + 7.2vw, 7.25rem);   /* 44 -> 116 */
  --fs-h1: clamp(2.5rem, 1.4rem + 5.2vw, 6.875rem);     /* 40 -> 110 */
  --fs-h2: clamp(2rem, 1.3rem + 3.3vw, 3.75rem);        /* 32 -> 60  */
  --fs-h3: clamp(1.375rem, 1.1rem + 1.3vw, 2.4375rem);  /* 22 -> 39  */
  --fs-h4: clamp(1.25rem, 1.1rem + 0.7vw, 1.625rem);    /* 20 -> 26  */
  --fs-lg: clamp(1.125rem, 1rem + 0.6vw, 1.5rem);       /* 18 -> 24  */
  --fs-body: 1rem;
  --fs-sm: 0.875rem;
  --fs-xs: 0.8125rem;
  --fs-label: 0.6875rem;

  --lh-tight: 0.96;
  --lh-snug: 1.2;
  --lh-body: 1.5;
  --ls-tight: -0.07em;
  --ls-snug: -0.04em;
  --ls-label: 0.08em;

  /* Layout */
  --wrap: 1920px;   /* live content caps at 1848px + 2x36px gutter */
  --wrap-narrow: 760px;
  --gutter: clamp(1.25rem, 2.8vw, 36px);   /* live measures a flat 36px at 1280 and 1728 */
  --sec-y: clamp(4rem, 2.5rem + 6vw, 9rem);
  /* How far .world-scroll rides up over the hero, so the hero's visible band
     (and everything centred in it) can subtract exactly this much. */
  --world-sheet-overlap: 24px;

  --radius: 14px;
  --radius-lg: 22px;

  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  --dur: 0.5s;
}

/* ------------------------------------------------------------------ reset */

*, *::before, *::after { box-sizing: border-box; }

/* Seamless crossfade between page loads, done by the browser: it snapshots the
   outgoing document, holds it, and crossfades to the incoming one, so no
   unpainted frame is ever shown. Every page ships this same stylesheet, which
   is what lets both sides of the navigation opt in. Browsers without support
   just navigate normally (no blank, no delay). Replaced a JS fade-out that
   flashed white, see the note in main.js. */
@view-transition { navigation: auto; }
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 0.32s;
  animation-timing-function: var(--ease);
}

html {
  -webkit-text-size-adjust: 100%;
  /* The site is light-only (see the "no dark mode" note further down). This
     tells the browser so, so an OS-dark visitor doesn't get auto-darkened
     native UI (form controls on the contact page, scrollbars, spellcheck
     underlines) sitting on our light surfaces. */
  color-scheme: light;
  /* Canvas colour, so the area behind body is never the browser default white.
     body carries the same value, but html is what paints during navigation and
     while overscrolling. */
  background: var(--bg);
  /* No scroll-behavior:smooth, it fights Lenis. */
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-sans);
  font-size: var(--fs-body);
  line-height: var(--lh-body);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: hidden;
}
/* Effectively instant rather than `animation: none`, which can leave the
   outgoing snapshot stuck on screen. */
@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) { animation-duration: 1ms; }
}

img, video, svg, iframe { display: block; max-width: 100%; }
img { height: auto; }

a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }

h1, h2, h3, h4, h5, h6 { margin: 0; font-weight: 600; letter-spacing: var(--ls-snug); line-height: var(--lh-snug); }
p { margin: 0; }
ul, ol { margin: 0; padding: 0; list-style: none; }

:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; border-radius: 4px; }
/* Suppresses only the one restored-focus ring left on a lightbox trigger
   right after closing it (see main.js closeBox); a genuine Tab to the same
   button afterwards still shows the ring normally. */
[data-suppress-ring]:focus-visible { outline: none; }

.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* Skip link. Hidden until a KEYBOARD user focuses it.
   Two things were wrong before and both are fixed here.
   1. It revealed on :focus, which matches any focus at all, including the
      focus a browser hands out by itself. iOS Safari restores focus to the
      first focusable element after some scroll and toolbar gestures, and since
      this is the first focusable element on every page, "Skip to content"
      appeared unprompted when scrolling back to the top of a phone. Gating on
      :focus-visible means it only shows when the browser judges a focus ring
      is warranted, which is keyboard navigation, not touch.
   2. It relied on the transform alone to hide, so anything that neutralised
      that transform would park a black pill at the top of the page. opacity
      and pointer-events make it invisible and untappable independently of the
      transform, so no single failure can reveal it.
   position:fixed rather than absolute so it is anchored to the viewport and
   cannot drift with the document as it scrolls. */
.skip-link {
  position: fixed; left: 50%; top: 0; transform: translate(-50%, -140%);
  z-index: 200; background: var(--ink); color: var(--white);
  padding: 0.75rem 1.25rem; border-radius: 0 0 10px 10px; font-size: var(--fs-sm);
  opacity: 0;
  pointer-events: none;
  transition: transform 0.2s var(--ease), opacity 0.2s var(--ease);
}
.skip-link:focus-visible {
  transform: translate(-50%, 0);
  opacity: 1;
  pointer-events: auto;
}
/* Browsers without :focus-visible must still show it on plain focus, or
   keyboard users lose the skip link entirely. Only they get this rule. */
@supports not selector(:focus-visible) {
  .skip-link:focus { transform: translate(-50%, 0); opacity: 1; pointer-events: auto; }
}

/* ----------------------------------------------------------------- layout */

.wrap {
  width: 100%;
  max-width: var(--wrap);
  margin-inline: auto;
  padding-inline: var(--gutter);
}
/* The brands band uses a 48px gutter rather than the site's 36px. */
.section--brands .wrap { padding-inline: 48px; }
.wrap--narrow { max-width: var(--wrap-narrow); }

/* position:relative here isn't cosmetic: it's what stops the homepage's
   fixed hero video from painting on top of every section. Per CSS stacking
   rules, position:fixed content ALWAYS paints above position:static in-flow
   content within the same stacking context, regardless of DOM order. Without
   this, only .section--proof (which had its own explicit position:relative
   for the rounded reveal card) stacked correctly; every section after it
   let the fixed video bleed through. */
.section { position: relative; padding-block: var(--sec-y); }
.section--dark { background: var(--bg-dark); color: var(--dk-ink); }
.section--alt { background: var(--bg-alt); }
.section--flush { padding-block: 0; }

/* ------------------------------------------------------------- typography */

.t-hero {
  font-size: var(--fs-hero);
  line-height: var(--lh-tight);
  letter-spacing: var(--ls-tight);
  font-weight: 600;
}
.t-h1 { font-size: var(--fs-h1); line-height: var(--lh-tight); letter-spacing: var(--ls-tight); }
.t-h2 { font-size: var(--fs-h2); line-height: var(--lh-tight); letter-spacing: var(--ls-tight); }
.t-h3 { font-size: var(--fs-h3); line-height: 1.1; letter-spacing: var(--ls-tight); }
.t-h4 { font-size: var(--fs-h4); line-height: 1.3; }
.t-lead { font-size: var(--fs-lg); color: var(--ink-mut); line-height: 1.4; letter-spacing: -0.02em; }

.t-label {
  font-size: var(--fs-label);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  font-weight: 600;
  color: var(--ink-faint);
}
.section--dark .t-label { color: rgba(255, 255, 255, 0.5); }
.section--dark .t-lead { color: var(--dk-ink-mut); }

.t-accent { color: var(--primary); }

/* --------------------------------------------------------------- controls */

.btn {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.95rem 1.6rem;
  border-radius: 100px;
  font-size: var(--fs-sm);
  font-weight: 600;
  letter-spacing: -0.01em;
  transition: transform 0.35s var(--ease), background-color 0.35s var(--ease), color 0.35s var(--ease), border-color 0.35s var(--ease);
  will-change: transform;
}
.btn--primary { background: var(--primary-cta); color: var(--white); }
.btn--solid { background: var(--ink); color: var(--white); }
.btn--ghost { border: 1px solid var(--line); color: var(--ink); }
.section--dark .btn--ghost { border-color: var(--dk-line); color: var(--dk-ink); }

@media (hover: hover) {
  .btn:hover { transform: translateY(-2px); }
  .btn--ghost:hover { background: var(--ink); color: var(--white); border-color: var(--ink); }
  .section--dark .btn--ghost:hover { background: var(--white); color: var(--ink); border-color: var(--white); }
}

/* Button row. Named for the old footer CTA block it started life in; the 404
   page in build/03-pages.mjs still uses it, so it stays as a plain utility. */
.footer-cta__actions { display: flex; flex-wrap: wrap; gap: 0.75rem; }

/* ----------------------------------------------------------------- header */

.site-header {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 100;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 1.5rem;
  padding: 1.15rem var(--gutter);
  /* Dark theme always carries this scrim, not just once "stuck": the header
     floats over whatever section happens to be scrolled underneath it, and a
     light section (e.g. the contact canvas background) made white nav text
     unreadable when the scrim was scroll-gated. Permanent low scrim, slightly
     stronger and blurred once stuck, rather than none-vs-something. */
  background: rgba(12, 12, 12, 0.32);
  backdrop-filter: saturate(140%) blur(6px);
  -webkit-backdrop-filter: saturate(140%) blur(6px);
  transition: background-color 0.4s var(--ease), backdrop-filter 0.4s var(--ease);
}
.site-header.is-stuck {
  background: rgba(12, 12, 12, 0.72);
  backdrop-filter: saturate(140%) blur(14px);
  -webkit-backdrop-filter: saturate(140%) blur(14px);
}
/* Light theme: dark text on an always-opaque white bar, for pages where the
   header sits on its own light strip rather than floating over a dark hero
   (e.g. the world listings). Opaque at rest too, not just once stuck. */
.site-header[data-theme="light"] {
  background: var(--white);
}
.site-header[data-theme="light"].is-stuck {
  background: rgba(255, 255, 255, 0.88);
  backdrop-filter: saturate(140%) blur(14px);
  -webkit-backdrop-filter: saturate(140%) blur(14px);
}
/* Solid black bar, white text, no transparency at any scroll position.
   Used on 3dworld/genworld/contact for a single consistent header across
   those three (by request, not a fidelity match to the reference site). */
.site-header[data-theme="black"],
.site-header[data-theme="black"].is-stuck {
  background: #0c0c0c;
  backdrop-filter: none;
  -webkit-backdrop-filter: none;
}

.brand {
  font-family: var(--font-display);
  font-size: clamp(1.6rem, 1.2rem + 1.2vw, 2.3125rem); /* live: 37px at 1280 */
  letter-spacing: 0.01em;
  color: var(--white);
  line-height: 1;
  justify-self: start;
}
.site-header[data-theme="light"] .brand,
.site-header[data-theme="light"] .nav-link { color: var(--ink); }

/* Centered as a group between the logo and the toggle (grid columns 1fr auto
   1fr), not right-aligned next to the logo. */
.nav { display: flex; align-items: center; gap: 0.4rem; justify-self: center; }
.nav-link {
  color: var(--white);
  font-size: 14px;
  font-weight: 700;
  letter-spacing: -0.05em;
  text-transform: uppercase;
  text-align: center;
  border-radius: 100px;
  padding: 0.6rem 1.15rem;
  min-width: 124px;
  border: 1px solid transparent;
  transition: border-color 0.3s var(--ease), background-color 0.3s var(--ease);
}
@media (hover: hover) { .nav-link:hover { border-color: currentColor; } }
.nav-link[aria-current="page"] {
  border-color: currentColor;
  background: rgba(128, 128, 128, 0.08);
}

/* Hamburger, mobile only. Desktop uses the inline nav. */
/* grid-column pins this explicitly: .nav is position:fixed, and per spec
   out-of-flow items are skipped entirely by grid auto-placement, which
   shifts every following sibling's implicit column back by one, silently
   landing this in the middle (auto, 44px) track instead of the third (1fr)
   one, off-center instead of flush right. */
.nav-toggle { display: none; grid-column: 3; width: 44px; height: 44px; align-items: center; justify-content: center; justify-self: end; }
.nav-toggle span {
  display: block; width: 22px; height: 1.5px; background: var(--white);
  position: relative; transition: background-color 0.2s var(--ease);
}
.nav-toggle span::before,
.nav-toggle span::after {
  content: ""; position: absolute; left: 0; width: 22px; height: 1.5px; background: var(--white);
  transition: transform 0.4s var(--ease);
}
.nav-toggle span::before { top: -7px; }
.nav-toggle span::after { top: 7px; }
.nav-open .nav-toggle span { background: transparent; }
.nav-open .nav-toggle span::before { transform: translateY(7px) rotate(45deg); }
.nav-open .nav-toggle span::after { transform: translateY(-7px) rotate(-45deg); }

@media (max-width: 900px) {
  .nav-toggle { display: flex; }
  .nav {
    position: fixed;
    /* top/left + explicit vw/vh instead of inset:0: .site-header's
       backdrop-filter makes IT the containing block for this fixed child
       (backdrop-filter, like transform/filter/contain, creates one per
       spec), so inset:0 resolved against the ~80px header bar instead of
       the viewport, leaving the "closed" menu only translated up by 80px
       and visible at the top of the screen instead of fully off-screen.
       vw/vh are always viewport-relative regardless of containing block,
       so they sidestep the quirk instead of needing to restructure the DOM. */
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    flex-direction: column;
    justify-content: center;
    gap: 1.5rem;
    background: var(--bg-dark);
    transform: translateY(-100%);
    transition: transform 0.55s var(--ease);
    z-index: -1;
  }
  .nav-open .nav { transform: translateY(0); }
  .nav-link { font-size: 1.75rem; letter-spacing: -0.03em; text-transform: none; font-weight: 600; }
}

/* ----------------------------------------------------------------- footer */

/* Two-panel glassmorphism footer, ported 1:1 from the Framer GlassFooter
   component (_extract/code/GlassFooter.tsx). Every value below is the
   component's own default, cross-checked against the live site at 1280px.
   Sizes stay in px on purpose: the Framer source is px, and rem would drift
   against a user-changed root font size.

   Framer switched layout on CONTAINER width via ResizeObserver. The footer is
   full-bleed, so container width equals viewport width and plain media queries
   reproduce it exactly. Source breakpoints: phone < 480, tablet 480-819,
   desktop >= 820. Desktop is the base here; the two max-width blocks step down. */

.site-footer {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  background: #060608;
  color: var(--dk-ink);
  padding: 64px;
  font-family: var(--font-sans);
}

/* Ambient blurred blobs. Decorative only, never hit-testable. */
/* Footer text alphas: the Framer export set secondary copy here between 0.15
   and 0.28 white, which lands at 1.5:1 to 2.5:1 on this near-black panel. "All
   rights reserved." at 0.15 was effectively invisible and the legal links were
   barely better, which reads as unfinished rather than understated. On these
   backgrounds 0.45 is exactly the 4.5:1 AA line, so the scale now starts above
   it with room to spare: 0.5 for the two legal/copyright lines, 0.55 for
   non-interactive list text and column heads, 0.62 for anything clickable so
   links stay the brightest thing in the block. Hierarchy is preserved, it just
   starts from a legible floor. Do not push these back under 0.45. */
.gf__blob {
  position: absolute;
  display: block;
  border-radius: 50%;
  filter: blur(100px);
  pointer-events: none;
  z-index: 0;
}
.gf__blob--a { width: 600px; height: 600px; top: -200px; left: 10%; background: rgba(230, 51, 41, 0.14); }
.gf__blob--b { width: 300px; height: 300px; bottom: -60px; right: 5%; background: rgba(60, 80, 200, 0.1); }

.gf__inner {
  position: relative;
  z-index: 1;
  max-width: var(--wrap);
  margin-inline: auto;
}

.gf__cards {
  display: grid;
  grid-template-columns: 1fr 2fr;
  gap: 20px;
  margin-bottom: 20px;
  align-items: stretch;
}

.gf__card {
  border-radius: 18px;
  padding: 44px;
  -webkit-backdrop-filter: blur(20px);
  backdrop-filter: blur(20px);
  display: flex;
  flex-direction: column;
}
.gf__card--cta {
  justify-content: center;
  background: rgba(230, 51, 41, 0.08);
  border: 1px solid rgba(230, 51, 41, 0.2);
}
.gf__card--dir {
  justify-content: space-between;
  background: rgba(255, 255, 255, 0.04);
  border: 1px solid rgba(255, 255, 255, 0.08);
}

/* ---- left card ---- */

.gf__headline {
  font-family: var(--font-heading);
  font-size: 26px;
  font-weight: 600;
  letter-spacing: -0.02em;
  line-height: 1.2;
  color: #ffffff;
  margin: 0 0 16px;
}
.gf__body {
  font-size: 13px;
  font-weight: 400;
  line-height: 1.65;
  color: rgba(255, 255, 255, 0.62);
  margin: 0 0 28px;
}

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

.gf__btn {
  display: block;
  text-align: center;
  font-family: var(--font-heading);
  font-size: 13px;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  line-height: 1.2;
  padding: 13px 22px;
  border-radius: 8px;
  margin-bottom: 10px;
  border: 1px solid transparent;
  transition: background-color 180ms ease, border-color 180ms ease;
}
.gf__btn--last { margin-bottom: 0; }
.gf__btn--primary { background: var(--primary-cta); color: #ffffff; }
.gf__btn--ghost {
  background: rgba(255, 255, 255, 0.06);
  border-color: rgba(255, 255, 255, 0.1);
  color: #ffffff;
}
/* The arrow (U+2192) is outside the Latin subset of both Space Grotesk and
   Inter, so it renders from the system sans. Same as the live Framer build. */
.gf__btn-arrow { font-family: sans-serif; }

@media (hover: hover) {
  .gf__btn--primary:hover { background: #b62916; }
  .gf__btn--ghost:hover { background: rgba(255, 255, 255, 0.1); }
}

/* ---- right card ---- */

.gf__cols {
  display: grid;
  grid-template-columns: 3fr 1fr;
  gap: 40px;
  margin-bottom: 32px;
}

.gf__colhead {
  font-family: var(--font-heading);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.18em;
  line-height: 1.2;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.55);
  margin: 0 0 16px;
}

.gf__services {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 11px 28px;
}
.gf__services li {
  font-size: 14px;
  font-weight: 400;
  line-height: 1.2;
  color: rgba(255, 255, 255, 0.55);
  white-space: nowrap;
}

.gf__navlist { display: flex; flex-direction: column; gap: 11px; }
.gf__navlist a {
  font-size: 14px;
  font-weight: 400;
  line-height: 1.2;
  color: rgba(255, 255, 255, 0.62);
  transition: color 150ms ease;
}

.gf__meta {
  padding-top: 24px;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
  display: flex;
  justify-content: space-between;
  align-items: center;
}
.gf__copyright { font-size: 12px; color: rgba(255, 255, 255, 0.5); }

.gf__socials { display: flex; flex-wrap: wrap; gap: 20px; }
.gf__socials a {
  font-family: var(--font-heading);
  font-size: 12px;
  letter-spacing: 0.04em;
  color: rgba(255, 255, 255, 0.62);
  transition: color 150ms ease;
}

/* ---- base bar ---- */

.gf__base {
  background: rgba(255, 255, 255, 0.02);
  border: 1px solid rgba(255, 255, 255, 0.05);
  border-radius: 12px;
  padding: 20px 44px;
  display: flex;
  justify-content: space-between;
  align-items: center;
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
}
.gf__rights { font-size: 12px; color: rgba(255, 255, 255, 0.5); }

.gf__legal { display: flex; flex-wrap: wrap; gap: 28px; }
.gf__legal a {
  font-size: 12px;
  color: rgba(255, 255, 255, 0.62);
  transition: color 150ms ease;
}

@media (hover: hover) {
  .gf__navlist a:hover { color: #ffffff; }
  .gf__socials a:hover { color: rgba(255, 255, 255, 0.75); }
  .gf__legal a:hover { color: rgba(255, 255, 255, 0.6); }
}

/* Low-opacity link text is intentional (it matches the live design), so the
   focus ring has to carry the whole affordance for keyboard users. */
.site-footer a:focus-visible {
  outline: 2px solid #ffffff;
  outline-offset: 3px;
  border-radius: 4px;
  color: #ffffff;
}

/* Tablet: 480-819. Cards stack, padding steps down. */
@media (max-width: 819.98px) {
  .site-footer { padding: 32px; }
  .gf__cards { grid-template-columns: 1fr; }
  .gf__card { padding: 32px; }
}

/* Phone: < 480. */
@media (max-width: 479.98px) {
  .site-footer { padding: 20px; }
  .gf__blob--a { width: 300px; height: 300px; }
  .gf__blob--b { width: 180px; height: 180px; }
  .gf__cards { gap: 16px; margin-bottom: 16px; }
  .gf__card { padding: 28px; border-radius: 14px; }
  .gf__headline { font-size: 22px; }
  .gf__cols { grid-template-columns: 1fr; gap: 28px; }
  .gf__services { grid-template-columns: 1fr; }
  .gf__services li { white-space: normal; }
  .gf__meta { flex-direction: column; align-items: flex-start; gap: 12px; }
  .gf__socials { gap: 16px; }
  .gf__base {
    border-radius: 10px;
    padding: 16px 20px;
    flex-direction: column;
    align-items: flex-start;
    gap: 12px;
  }
  .gf__legal { gap: 16px; }
}

/* backdrop-filter is progressive enhancement; without it the cards still read
   as glass because their own translucent fills carry the look. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .gf__card--dir { background: rgba(255, 255, 255, 0.055); }
  .gf__base { background: rgba(255, 255, 255, 0.035); }
}

/* ------------------------------------------------------------------ cards */

.grid {
  display: grid;
  gap: clamp(1rem, 0.5rem + 1.4vw, 1.75rem);
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 320px), 1fr));
}

.card { position: relative; }
.card__media {
  position: relative;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border-radius: var(--radius);
  background: var(--bg-alt);
}
.card__media img {
  width: 100%; height: 100%;
  object-fit: cover;
  transition: transform 0.7s var(--ease);
}
@media (hover: hover) { .card:hover .card__media img { transform: scale(1.04); } }

/* The card body reserves the tallest case (two title lines plus the agency
   line) so every card is the same height, but the title itself is NOT given a
   floor. Putting the floor on the title padded the title box and opened a gap
   between a one-line title and its agency credit; putting it here sends the
   slack to the BOTTOM of the card, where it reads as intentional whitespace.
   Computed from the same tokens the children use, so it tracks if they move. */
.card__body { padding-top: 0.85rem; display: flex; flex-direction: column; gap: 0.25rem; }
/* Card text is CLAMPED, not merely reserved. min-height alone only sets a
   floor, so a long title still grew to three or four lines on a narrow card and
   the grid stayed ragged. Two lines for the title and one for the meta means
   every card body is exactly the same height at any width, and a project with
   no agency simply shows the empty line rather than collapsing. */
.card__title {
  line-height: 1.35;
  display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
  font-size: var(--fs-sm); font-weight: 600; letter-spacing: -0.02em;
}
.card__meta {
  line-height: 1.35; min-height: 1.35em;
  display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden;
  font-size: var(--fs-label); color: var(--ink-faint); letter-spacing: -0.01em;
}

.badge {
  position: absolute;
  right: 0.6rem; bottom: 0.6rem;
  background: rgba(0, 0, 0, 0.72);
  color: var(--white);
  font-size: 10px; font-weight: 600;
  padding: 0.2rem 0.45rem;
  border-radius: 5px;
  backdrop-filter: blur(6px);
}

.play {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  opacity: 0;
  transition: opacity 0.4s var(--ease);
  background: linear-gradient(180deg, rgba(0,0,0,0.05), rgba(0,0,0,0.35));
}
@media (hover: hover) { .card:hover .play { opacity: 1; } }
.play svg { width: 46px; height: 46px; }

/* --------------------------------------------------------------- filters */

.filters { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-bottom: clamp(1.5rem, 3vw, 2.5rem); }
.pill {
  padding: 0.5rem 1rem;
  border-radius: 100px;
  border: 1px solid var(--line);
  font-size: var(--fs-xs);
  font-weight: 500;
  line-height: 1.2;
  color: var(--ink);
  transition: background-color 0.3s var(--ease), color 0.3s var(--ease), border-color 0.3s var(--ease);
}
.pill[aria-pressed="true"] { background: var(--ink); color: var(--white); border-color: var(--ink); }
@media (hover: hover) { .pill:hover { border-color: var(--ink); } }
.pill__count { opacity: 0.5; margin-left: 0.3rem; }

/* ------------------------------------------------------------------ prose */

.prose { max-width: 68ch; }
.prose p { margin-block: 0 1.15em; color: var(--ink-2); font-size: var(--fs-lg); line-height: 1.6; }
.prose h2 { font-size: var(--fs-h3); margin-block: 1.8em 0.55em; letter-spacing: -0.05em; }
.prose h3 { font-size: var(--fs-h4); margin-block: 1.5em 0.5em; font-weight: 500; }
.prose strong { color: var(--ink); font-weight: 600; }
/* Citation/source links inside article bodies use a distinct teal, not the
   brand accent, per GlobalStyles.tsx's .cg-article-link rule (kept separate
   from --primary so inline citations read as references, not CTAs). */
.prose a {
  color: #006d65;
  font-weight: 600;
  text-decoration: underline;
  text-decoration-color: rgba(0, 109, 101, 0.4);
  text-underline-offset: 3px;
  transition: color 0.2s ease;
}
@media (hover: hover) { .prose a:hover { color: #00897b; } }
.prose img { border-radius: var(--radius); margin-block: 1.5em; }
.prose iframe { width: 100%; aspect-ratio: 16 / 9; height: auto; border: 0; border-radius: var(--radius); margin-block: 1.5em; }
.prose ul { margin-block: 0 1.15em; padding-left: 1.25em; }
.prose li { list-style: disc; color: var(--ink-2); font-size: var(--fs-lg); line-height: 1.6; margin-block: 0 0.5em; }
.prose li:last-child { margin-bottom: 0; }
.prose > *:first-child { margin-top: 0; }


/* --------------------------------------------------------------- reveals */

.rv { opacity: 1; transform: none; }

/* Reveals are opt-in: main.js adds .js-reveal to <html> only when it can
   guarantee it will un-hide them. Without JS, or if IntersectionObserver never
   fires (backgrounded tab, prerender, older engines), content stays visible. */
.js-reveal .rv {
  opacity: 0;
  transform: translateY(18px);
  transition: opacity 0.8s var(--ease), transform 0.8s var(--ease);
}
.js-reveal .rv.on { opacity: 1; transform: none; }

@media (prefers-reduced-motion: reduce) {
  .rv { opacity: 1; transform: none; transition: none; }
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
}

/* --------------------------------------------------------- no dark mode */
/* Removed deliberately: this is a light design with dark BANDS (the heroes and
   the brands strip use --bg-dark and are unaffected by OS preference, so the
   dark look survives here regardless). An OS-dark variant inverted the light
   content sheets and flattened that alternation, and because several surfaces
   are fixed light tokens while their text follows --ink, it also produced
   white-on-white: the whole header on every case study plus privacy/terms/404,
   the work filter pills, and the focused skip link. Rather than maintain a
   second colour scheme nobody art-directed, the site now always renders light.
   color-scheme below also stops the browser auto-darkening native UI (form
   controls in the contact form, scrollbars) for OS-dark visitors. */

/* ==========================================================================
   Page sections
   ========================================================================== */

/* ------------------------------------------------------------------ hero */

/* ---------------------------------------------------------------- preloader */

/* Homepage only. The curtain is gated on .is-preloading, which only the inline
   head script in layout.mjs adds, so a visitor without JS never sees it and can
   never be stranded behind it. preloader.js removes both the class and the node.
   Scrolling is locked while it is up: the page underneath is already laid out,
   and a wheel event landing on invisible content is disorienting. */
html.is-preloading, html.is-preloading body { overflow: hidden; }

/* The curtain defaults to INVISIBLE and is only shown while <html> carries
   .is-preloading. That direction matters and is not cosmetic: the overlay ships
   in the HTML, so if it defaulted to visible then anyone whose JS never runs
   (disabled, blocked, a failed request for preloader.js) would be left staring
   at a permanent black screen with the whole site behind it. Defaulting to
   hidden means the worst case is simply no curtain.
   It also makes removing the class the fade trigger, so there is one mechanism
   instead of two: opacity and visibility both transition, and visibility is
   what stops it swallowing clicks the instant the fade ends. */
.preloader {
  position: fixed;
  inset: 0;
  z-index: 9999;
  display: grid;
  place-items: center;
  background: var(--bg-dark);
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transition: opacity 0.6s var(--ease), visibility 0.6s var(--ease);
}
html.is-preloading .preloader { opacity: 1; visibility: visible; pointer-events: auto; }

/* The portal/particle artwork fills the curtain and sits behind the wordmark.
   pointer-events:none so the pointermove listener on .preloader still sees the
   whole surface rather than only the canvas box. */
.preloader__canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

.preloader__inner { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 22px; }

.preloader__mark {
  font-family: var(--font-display);
  font-size: clamp(1.5rem, 1rem + 2.4vw, 2.75rem);
  letter-spacing: 0.005em;
  color: var(--white);
  opacity: 0.92;
}

/* An indeterminate sweep, not a fake percentage. We genuinely do not know how
   far along the video is, and a progress bar that invents numbers is a lie the
   visitor can feel when it stalls at 90%. */
.preloader__bar {
  position: relative;
  width: clamp(120px, 18vw, 190px);
  height: 2px;
  border-radius: 2px;
  background: rgba(255, 255, 255, 0.14);
  overflow: hidden;
}
.preloader__bar-fill {
  position: absolute;
  inset: 0 auto 0 0;
  width: 40%;
  border-radius: 2px;
  background: var(--primary);
  animation: preloader-sweep 1.15s var(--ease) infinite;
}
@keyframes preloader-sweep {
  0%   { transform: translateX(-100%); }
  100% { transform: translateX(350%); }
}

/* preloader.js lifts almost immediately under reduced motion, but the curtain
   still paints for those few frames, so it must not animate while it is up. */
@media (prefers-reduced-motion: reduce) {
  .preloader { transition: none; }
  .preloader__bar-fill { animation: none; width: 100%; }
}

.hero {
  position: relative;
  min-height: 100svh;
  display: flex;
  align-items: flex-end;
  padding-block: 0 clamp(3rem, 2rem + 5vw, 7rem);
  background: var(--bg-dark);
  color: var(--dk-ink);
  isolation: isolate;
}
/* Fixed to the viewport (not absolute within .hero): the video stays put as
   the page scrolls, while the hero text below and the next section both move
   normally, so the section behind visibly slides up and over the video. */
.hero__media { position: fixed; inset: 0; z-index: 0; background: var(--bg-dark); }
.hero__media::after {
  content: "";
  position: absolute; inset: 0;
  background: linear-gradient(180deg, rgba(12,12,12,.55) 0%, rgba(12,12,12,.15) 45%, rgba(12,12,12,.85) 100%);
  z-index: 1;
}
.hero__video {
  position: absolute;
  top: 50%; left: 50%;
  width: 100vw; height: 56.25vw;
  min-height: 100%; min-width: 177.77vh;
  transform: translate(-50%, -50%);
  border: 0;
  pointer-events: none;
}

/* The iframe is NEVER faded out, and that is deliberate.
   It used to sit at opacity 0 until its `load` event, which meant that at the
   exact moment Vimeo's player booted and asked to autoplay, the element was
   not being painted. Browsers decline to start media that is not actually
   displayed, so on Safari we were handing it a reason to refuse and then
   treating the refusal as a mystery. The hero is also inside a
   `position: fixed` parent and, on first load, behind the preloader curtain,
   so the autoplay request had every reason to be denied.
   The fade still exists, it just runs on a veil PAINTED OVER the iframe
   instead of on the iframe itself. Occlusion by a sibling is not the same as
   being unpainted: the iframe stays visible as far as the media policy is
   concerned, while the viewer still sees a clean fade up from black. */
.hero__veil {
  position: absolute;
  inset: 0;
  z-index: 2;
  background: var(--bg-dark);
  opacity: 1;
  transition: opacity 1.2s var(--ease);
  pointer-events: none;
}
.hero__video.playing ~ .hero__veil { opacity: 0; }
/* Used only while the player is being rebuilt, so the curtain covers the
   reload instantly instead of fading in after the flash has happened. */
.hero__veil--snap { transition: none; }
.hero__inner { position: relative; z-index: 1; width: 100%; }
.hero__tagline { color: rgba(255,255,255,.6); margin-bottom: 0.75rem; }
.hero__title { font-family: var(--font-display); font-weight: 400; letter-spacing: 0.005em; color: var(--white); }

/* ----------------------------------------------------------------- stats */

.section--stats { padding-block: clamp(3rem, 2rem + 4vw, 6rem); }
.stats {
  --stats-gap: clamp(1.25rem, 0.5rem + 2vw, 2.5rem);
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--stats-gap);
}
.stat { position: relative; }
/* Thin divider centered in the gap, ties the three columns together now
   that the centered labels/sub-text leave more open space around them. */
.stat:not(:last-child)::after {
  content: "";
  position: absolute;
  top: 6px;
  bottom: 6px;
  right: calc(var(--stats-gap) / -2);
  width: 1px;
  background: var(--line);
}
.stat__num {
  display: flex;
  align-items: baseline;
  font-size: clamp(2.5rem, 1.5rem + 3.9vw, 4.375rem); /* live: 70px at 1280 */
  font-weight: 600;
  letter-spacing: -0.07em;
  justify-content: center;
  line-height: 0.9;
  color: var(--ink);
}
/* Live renders the suffix at the same size as the number, not shrunken. */
.stat__suffix { font-size: 1em; font-weight: inherit; letter-spacing: inherit; }
.stat__label { font-size: var(--fs-h4); margin-top: 0.7rem; text-align: center; }
.stat__sub { font-size: 16px; font-weight: 500; line-height: 1.3; letter-spacing: -0.04em; color: var(--ink-mut); margin-top: 0.25rem; text-align: center; }

@media (max-width: 760px) {
  .stats { grid-template-columns: 1fr; gap: 2.25rem; }
  .stat:not(:last-child)::after { display: none; }
}

/* ------------------------------------------------------------- headings */

.section-head { margin-bottom: clamp(2rem, 1rem + 3vw, 3.5rem); }
/* Centered heads run full width on the live site (the heading is one line at
   60px); only the sub-line is constrained. */
.section-head--center { text-align: center; }
.section-head--center .t-lead { max-width: 42ch; margin-inline: auto; }
.section-head .t-lead { margin-top: 0.75rem; }
.proof-heading { margin-bottom: clamp(2rem, 1rem + 3vw, 3.5rem); }

/* ---------------------------------------------------------------- brands */

/* Measured live at 1280px: text column 344px, logo grid 840px (about 1 : 2.4),
   not the even split this previously used. */
/* Measured on the live site at 1280: a 280px text column + 64px gap puts the
   logo grid at exactly x=392 with 840px to work in. */
.brands { display: grid; grid-template-columns: 280px 1fr; gap: 64px; align-items: center; }
/* Live measures this text block at 280px inside a 344px column, which is what
   makes "Brands that / trust us." break to two lines and the body copy to three. */
.brands__intro { max-width: 280px; }
/* Live: 14px / 1.7 / rgba(255,255,255,.45), noticeably smaller than the
   shared .t-lead size used elsewhere. */
.brands__intro .t-lead {
  margin-top: 1rem;
  font-size: 14px;
  line-height: 1.7;
  letter-spacing: normal;
  color: var(--dk-ink-mut);
}
.brands__stats { display: flex; gap: 2.5rem; margin-top: 2rem; }
.brands__stats div { display: flex; flex-direction: column; }
.brands__stats strong { font-size: 26px; font-weight: 700; letter-spacing: -0.04em; }
.brands__stats span { font-size: 11px; font-weight: 500; letter-spacing: 0.08em; text-transform: uppercase; color: var(--dk-ink-mut); margin-top: 0.35rem; }
@media (max-width: 900px) { .brands { grid-template-columns: 1fr; } }

/* -------------------------------------------------------------- services */
/* Measured off the live site at 1280px (visible breakpoint variant only,
   Framer ships hidden variants that report width 0 and mislead inspection):
   4 columns, 20px gap, card 287x596 white with 25px radius and no padding,
   image inset 10px on three sides, copy inset 40px. */

.services { display: grid; grid-template-columns: repeat(4, 1fr); gap: 20px; }
@media (max-width: 1199px) { .services { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 809px)  { .services { grid-template-columns: 1fr; }
                             .service__media { height: 300px; } }
.service { position: relative; padding: 0; background: var(--bg); border-radius: 25px; border: 0; overflow: hidden; }
.service h3 { margin: 10px 40px 0; font-weight: 700; }
.service p { margin: 12px 40px 40px; color: var(--ink-mut); font-size: 16px; font-weight: 500; line-height: 1.3; letter-spacing: -0.04em; }

/* ---- cursor-tracked sheen (assets/js/service-cards.js) ----
   These cards had no hover state at all, which on a page where the hero, the
   brands wall and the booking panel all answer the cursor made this the one
   dead section on the homepage.
   They are <article>, NOT links, so the treatment stays restrained on purpose:
   a light that follows the pointer and a small lift. A big lift plus a strong
   shadow reads as a button and promises a click that does not exist.
   All of it sits inside @media (hover: hover): --mx/--my are only ever written
   by a real pointer, so on touch the gradient would be frozen at its 50%/50%
   fallback forever, and the resting card has to stand on its own. */
@media (hover: hover) {
  .service {
    transition: transform 0.45s var(--ease), box-shadow 0.45s var(--ease);
  }
  .service:hover {
    transform: translateY(-6px);
    box-shadow: 0 18px 40px rgba(12, 12, 12, 0.1), 0 2px 6px rgba(12, 12, 12, 0.05);
  }

  /* The sheen. Sits above the artwork but is only 12% of the brand red at its
     hottest, so it tints rather than washes, and the copy underneath stays at
     full contrast. Fallback of 50%/50% keeps it centred for the frames between
     :hover firing and the first pointermove landing. */
  .service::after {
    content: "";
    position: absolute;
    inset: 0;
    border-radius: inherit;
    pointer-events: none;
    background: radial-gradient(
      380px circle at var(--mx, 50%) var(--my, 50%),
      rgba(249, 69, 45, 0.12),
      transparent 62%
    );
    opacity: 0;
    transition: opacity 0.4s var(--ease);
  }
  .service:hover::after { opacity: 1; }

  /* Matches the scale used by the work and case cards, so a hovered card here
     behaves like every other card on the site rather than inventing a motion. */
  .service__media img {
    transition: transform 0.7s var(--ease);
    will-change: transform;
  }
  .service:hover .service__media img { transform: scale(1.05); }

  @media (prefers-reduced-motion: reduce) {
    .service, .service__media img, .service::after { transition: none; }
    .service:hover { transform: none; }
    .service:hover .service__media img { transform: none; }
    /* service-cards.js never runs under reduced motion, so the sheen would be
       pinned to the card centre. Suppress it rather than show a static blob. */
    .service:hover::after { opacity: 0; }
  }
}

@media (max-width: 1100px) { .services { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 600px) { .services { grid-template-columns: 1fr; } }

/* ---------------------------------------------------------------- worlds */

/* Measured live: section sits on a warm cream #F2F0EB (NOT dark, which is what
   this previously assumed), heading 72px in near-black, tiles are 340x240
   dark slate #1E2028 with 20px radius, and the caption sits BELOW each tile
   at 11px uppercase. */
/* ==========================================================================
   Choose your dimension (WorldSelector.tsx port)
   Ported from the real source after discovering the first attempt was built
   from a truncated extraction and silently downgraded to a static CSS
   mockup. The real component is genuinely interactive: the 3D card is a
   hover-triggered pseudo-3D "portal" (five nested frames pulling into depth
   via perspective + translateZ), the GEN card is a live 576-particle canvas
   simulation that idles and reacts to the cursor. See world-selector.js.
   ========================================================================== */

.ws {
  position: relative;
  width: 100%;
  overflow: hidden;
  background: #f2f0eb;
  color: #111;
}

.ws__bg { position: absolute; inset: 0; z-index: 0; pointer-events: none; }
.ws__dots {
  position: absolute; inset: 0;
  background-image: radial-gradient(circle, rgba(42,46,53,0.11) 1px, transparent 1px);
  background-size: 26px 26px;
}
.ws__vignette {
  position: absolute; inset: 0;
  background: radial-gradient(ellipse 72% 65% at 50% 50%, #f2f0eb 0%, transparent 70%);
}
.ws__chroma { position: absolute; inset: 0; mix-blend-mode: multiply; }
.ws__chroma--a { background: radial-gradient(ellipse 60% 50% at calc(50% - 22px) 52%, rgba(255,60,40,0.038) 0%, transparent 68%); }
.ws__chroma--b { background: radial-gradient(ellipse 60% 50% at calc(50% + 22px) 48%, rgba(40,60,255,0.038) 0%, transparent 68%); }
.ws__edge {
  position: absolute; inset: 0;
  background: radial-gradient(ellipse 95% 90% at 50% 50%, transparent 55%, rgba(0,0,0,0.04) 100%);
}

.ws__inner {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding-block: clamp(44px, 6.8vw, 80px) clamp(52px, 7.5vw, 84px);
  padding-inline: clamp(20px, 2.9vw, 40px);
  box-sizing: border-box;
}

.ws__heading {
  font-family: var(--font-sans);
  font-size: clamp(22px, 6.2vw, 72px);
  font-weight: 800;
  color: #111;
  letter-spacing: -0.03em;
  line-height: 1.05;
  white-space: nowrap;
  text-align: center;
}
.ws__sub {
  font-family: var(--font-sans);
  font-size: clamp(15px, 1.4vw, 19px);
  font-weight: 400;
  color: rgba(42, 46, 53, 0.75);
  text-align: center;
  margin-top: clamp(8px, 0.9vw, 14px);
}

.ws__cards {
  display: flex;
  flex-direction: row;
  align-items: flex-start;
  justify-content: center;
  flex-wrap: wrap;
  gap: 24px;
  width: 100%;
  max-width: 724px;
  margin-top: clamp(28px, 3.8vw, 48px);
}

.ws-card {
  text-decoration: none;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(10px, 1.2vw, 14px);
  cursor: pointer;
  flex: 1 1 260px;
  max-width: 340px;
  min-width: 0;
}

.ws-card__stage {
  display: block;
  width: 100%;
  height: clamp(178px, 50vw, 240px);
  border-radius: clamp(12px, 1.8vw, 20px);
  border: 1px solid rgba(255, 255, 255, 0.06);
  box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.05), 0 8px 24px rgba(0, 0, 0, 0.22);
  overflow: hidden;
  position: relative;
  transition: box-shadow 0.5s ease;
}
.ws-card--portal .ws-card__stage { background: #1e2028; perspective: 520px; }
.ws-card--ai .ws-card__stage { background: #1a1c22; }

.ws-card__glyph {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-bebas);
  font-size: clamp(40px, 11vw, 88px);
  line-height: 1;
  color: #fff;
  letter-spacing: 0.03em;
  user-select: none;
  text-shadow: none;
  transition: text-shadow 0.4s ease;
}

/* Portal frames: five nested rings that pull into simulated depth on hover. */
.ws-card__frames {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  transform-style: preserve-3d;
}
.ws-card__frame {
  position: absolute;
  border-style: solid;
  border-width: 1.5px;
  transition: transform 0.52s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.52s ease;
}
.ws-card__frame--1 { width: 64.7%; height: 61.7%; border-color: #3fe0d0; border-radius: 6px; opacity: 0.13; transition-delay: 0s; }
.ws-card__frame--2 { width: 52.4%; height: 50.0%; border-color: #62a8ff; border-radius: 5px; opacity: 0.10; transition-delay: 0.05s; }
.ws-card__frame--3 { width: 40.6%; height: 38.8%; border-color: #7cffd4; border-radius: 4px; opacity: 0.08; transition-delay: 0.10s; }
.ws-card__frame--4 { width: 29.4%; height: 27.9%; border-color: #aeefff; border-radius: 3px; opacity: 0.07; transition-delay: 0.15s; }
.ws-card__frame--5 { width: 18.2%; height: 17.5%; border-color: #ffffff; border-radius: 2px; opacity: 0.05; transition-delay: 0.20s; }

.ws-card__canvas { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 1; pointer-events: none; }

.ws-card__label {
  font-family: var(--font-sans);
  font-size: clamp(9px, 1vw, 11px);
  font-weight: 600;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  color: rgba(42, 46, 53, 0.75);
  transition: color 0.3s ease;
  user-select: none;
  text-align: center;
}

@media (hover: hover) {
  .ws-card--portal:hover .ws-card__stage {
    box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.10), 0 24px 64px rgba(0, 0, 0, 0.35), 0 0 40px #3fe0d022;
  }
  .ws-card--portal:hover .ws-card__frame--1 { opacity: 0.90; transform: translateZ(0px); }
  .ws-card--portal:hover .ws-card__frame--2 { opacity: 0.65; transform: translateZ(-22px); }
  .ws-card--portal:hover .ws-card__frame--3 { opacity: 0.44; transform: translateZ(-44px); }
  .ws-card--portal:hover .ws-card__frame--4 { opacity: 0.28; transform: translateZ(-64px); }
  .ws-card--portal:hover .ws-card__frame--5 { opacity: 0.15; transform: translateZ(-80px); }
  .ws-card--portal:hover .ws-card__glyph { text-shadow: 0 0 28px #3fe0d099, 0 0 56px #3fe0d044; }
  .ws-card--portal:hover .ws-card__label { color: #3fe0d0; }

  .ws-card--ai:hover .ws-card__stage {
    box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.10), 0 24px 64px rgba(0, 0, 0, 0.35), 0 0 40px #ff8f8722;
  }
  .ws-card--ai:hover .ws-card__glyph { text-shadow: 0 0 28px #ff8f87aa, 0 0 56px #ff8f8755; }
  .ws-card--ai:hover .ws-card__label { color: #ff8f87; }
}

/* The two worlds stay SIDE BY SIDE on phones. Stacking them turned a single
   "pick one of two" choice into a scroll, and the second card fell below the
   fold, so most visitors only ever saw 3D. The stage also drops from a 50vw
   square to a taller, narrower portal, which suits two columns on a 390px
   screen and echoes the portrait framing of the world heroes. */
@media (max-width: 700px) {
  .ws__cards { flex-direction: row; align-items: flex-start; gap: 12px; flex-wrap: nowrap; }
  .ws-card { flex: 1 1 0; min-width: 0; max-width: none; gap: 10px; }
  .ws-card__stage { height: clamp(150px, 44vw, 210px); }
  .ws-card__glyph { font-size: clamp(30px, 9vw, 52px); }
  .ws-card__label { font-size: 9px; letter-spacing: 0.12em; }
}

/* ---- looping idle animation for touch devices ----
   Both cards were built for a cursor: the portal's recession is a :hover state
   and the particle field only reacts to hoverAlpha, so on a phone this section
   was two dead tiles. These give each card its own ambient loop, matching the
   language of its world: rectangles rippling into depth for 3D, and (in
   world-selector.js) an expanding ring through the particle field for GEN.
   Scoped to (hover: none) so a desktop pointer still drives the real
   interaction rather than fighting an animation. */
@property --ws-pulse {
  syntax: "<number>";
  initial-value: 1;
  inherits: false;
}

@media (hover: none) {
  /* Each frame keeps its own resting opacity in --o and the animation scales
     it, so the depth gradient (bright near, faint deep) survives the pulse
     instead of every frame flashing to the same value. */
  .ws-card--portal .ws-card__frame {
    opacity: calc(var(--o) * var(--ws-pulse));
    animation: ws-portal-ripple 3.4s var(--ease) infinite;
  }
  .ws-card--portal .ws-card__frame--1 { --o: 0.5;  animation-delay: 0s; }
  .ws-card--portal .ws-card__frame--2 { --o: 0.42; animation-delay: 0.16s; }
  .ws-card--portal .ws-card__frame--3 { --o: 0.34; animation-delay: 0.32s; }
  .ws-card--portal .ws-card__frame--4 { --o: 0.26; animation-delay: 0.48s; }
  .ws-card--portal .ws-card__frame--5 { --o: 0.2;  animation-delay: 0.64s; }

  .ws-card--portal .ws-card__glyph {
    animation: ws-glyph-breathe 3.4s var(--ease) infinite;
  }
}

/* The stagger is what makes it a ripple rather than a blink: each frame runs
   the same curve 160ms after the one outside it, so brightness travels inward
   and the stack reads as depth being pulled through. */
@keyframes ws-portal-ripple {
  0%, 100% { --ws-pulse: 0.55; transform: translateZ(0); }
  45%      { --ws-pulse: 1.9;  transform: translateZ(-16px); }
}
@keyframes ws-glyph-breathe {
  0%, 100% { text-shadow: none; }
  45%      { text-shadow: 0 0 22px #3fe0d066, 0 0 44px #3fe0d033; }
}

@media (prefers-reduced-motion: reduce) {
  .ws-card--portal .ws-card__frame,
  .ws-card--portal .ws-card__glyph { animation: none; }
  .ws-card--portal .ws-card__frame { opacity: var(--o, 0.1); }
}

/* ------------------------------------------------------------ page heads */

.page-head { padding-top: clamp(7rem, 5rem + 8vw, 12rem); }
.page-head .t-label { margin-bottom: 0.75rem; }

/* ---------------------------------------------------------------- cards */

/* Fixed column counts (not auto-fill): cards get wider on large screens
 * instead of an extra column sneaking in. Project grid (.grid--work) matches
 * the real site's 4/3/2 taper (measured at 2000/900/600px). Case studies
 * (.grid--cases) is 3/2/1 by request, not a fidelity match. */
/* Case grid: justified last row.
   3 columns of equal cards left a hole whenever the count was not a multiple
   of 3, which is most of the time: 3dworld has 5 (one hole) and genworld has 4
   (two holes), and it read as missing content rather than as layout. The track
   is therefore SIX columns, with a normal card spanning 2 (so three per row as
   before) and the trailing cards of a partial row widened to close the gap.

   The two rules below are quantity queries, and they cover every count without
   being written per page:
     - a last child that also STARTS a row (3n+1) is alone, so it takes all 6;
     - if the second-to-last starts a row, the final two share it at 3 each.
   Worked through: 4 -> 3 then 1 full width. 5 -> 3 then 2 halves. 6 -> 3 + 3,
   untouched. 1 -> a single full-width card. Nothing to maintain when a case
   study is added. */
.grid--cases { grid-template-columns: repeat(6, 1fr); gap: 20px; }
.grid--cases > * { grid-column: span 2; }

/* Every case card is the SAME HEIGHT regardless of how many columns it spans.
   With the inherited 16/9 aspect-ratio a full-width card came out 1353x761,
   three times the height of the 438x246 cards beside it, so the justified last
   row fixed the horizontal gap by buying a huge vertical one and the section
   turned into a long scroll. Fixing the height instead is what makes it read as
   a mosaic: the wider cards simply reveal more of their image, because
   .card__media img is already width/height 100% + object-fit:cover, so nothing
   distorts, it just crops less tightly. Height has to override aspect-ratio
   explicitly, or the ratio keeps driving the box. */
.grid--cases .card__media {
  aspect-ratio: auto;
  height: clamp(190px, 17vw, 250px);
}
/* Per-card crop focus. Only about a third of a 16/9 image's height survives the
   fixed card height, so a centred crop can miss the subject entirely. The
   generator sets --focal on the <picture> for the cards that need it (see
   CASE_FOCAL in 03-pages.mjs) and it inherits down to the img here; everything
   else falls back to dead centre. */
.grid--cases .card__media img { object-position: var(--focal, 50% 50%); }
.grid--cases > :last-child:nth-child(3n + 1) { grid-column: span 6; }
.grid--cases > :nth-last-child(2):nth-child(3n + 1),
.grid--cases > :last-child:nth-child(3n + 2) { grid-column: span 3; }

.grid--work { grid-template-columns: repeat(4, 1fr); gap: 16px; }
@media (max-width: 900px) {
  /* Two per row: a card spans 3 of the 6. Only an odd count strands the last
     card, and it stretches full width. The 3-column rules above must be reset
     or they keep firing against a 2-up track and produce a 1.5-card row. */
  .grid--cases > *,
  .grid--cases > :last-child:nth-child(3n + 1),
  .grid--cases > :nth-last-child(2):nth-child(3n + 1),
  .grid--cases > :last-child:nth-child(3n + 2) { grid-column: span 3; }
  .grid--cases > :last-child:nth-child(2n + 1) { grid-column: span 6; }
  .grid--work { grid-template-columns: repeat(3, 1fr); }
}
@media (max-width: 600px) {
  /* One per row: every card takes the full track, so no quantity query applies. */
  .grid--cases > *,
  .grid--cases > :last-child:nth-child(3n + 1),
  .grid--cases > :nth-last-child(2):nth-child(3n + 1),
  .grid--cases > :last-child:nth-child(3n + 2),
  .grid--cases > :last-child:nth-child(2n + 1) { grid-column: span 6; }
  .grid--work { grid-template-columns: repeat(2, 1fr); }
}
.grid--posts { grid-template-columns: repeat(auto-fill, minmax(min(100%, 340px), 1fr)); }

/* World listing pages chain flush sections (hero/reel/case-studies) with
 * small, specific gaps rather than the generic --sec-y spacing, matching a
 * measured 52px gap between the case-studies row and this section on the
 * real site (ours was using the default ~126px section padding, a mismatch
 * that read as "too much empty space" / a broken-looking layout). */
.world-projects { padding-top: 3.25rem; padding-bottom: clamp(3rem, 2rem + 4vw, 5rem); }
.world-cases { padding-top: 4.75rem; }
.world-section-label { margin-bottom: 2rem; }

/* Both world listing sections sit inside a rounded panel on the real site:
   case studies on a white card with a layered drop shadow, all-projects on a
   flat light-grey card. Values measured off cglabs.io/3dworld. The panel's
   own 20px padding plus a 16px wrap gutter reproduces the 36px content inset
   the grids already used, so nothing shifts horizontally. */
.world-cases .wrap,
.world-projects .wrap { padding-inline: 16px; }
.world-panel { border-radius: 20px; padding: 20px; }
.world-panel--cases {
  background: var(--white);
  box-shadow:
    0 0.6px 0.6px -1.25px rgba(0, 0, 0, 0.72),
    0 2.29px 2.29px -2.5px rgba(0, 0, 0, 0.64),
    0 10px 10px -3.75px rgba(0, 0, 0, 0.25);
}
.world-panel--projects { border-radius: 25px; padding: 20px 20px 24px; background: #ebebeb; }

/* Project cards are self-contained white cards on the real site (12px radius,
   clipped, caption inside on white) rather than a bare image with the title
   floating underneath, and their play button is always visible, not hover-only. */
.grid--work .card { background: var(--white); border-radius: 12px; overflow: hidden; display: flex; flex-direction: column; }
/* The thumbnail sits flush against the caption strip, so its BOTTOM corners
   must be square: .card__media's own var(--radius) rounded all four, leaving
   two notches of card background showing mid-card. Zeroing it here lets the
   card's own 12px + overflow:hidden clip the top corners instead, which also
   removes the 14px-inside-12px seam the two radii used to make. The container
   keeps its full radius. Scoped to .grid--work so case cards, whose media is
   a standalone rounded tile with the title overlaid, are unaffected. */
.grid--work .card__media { border-radius: 0; }
.grid--work .card__body { padding: 9px 10px 7px; gap: 0.1rem; flex: 1 1 auto; }
.grid--work .play {
  inset: auto;
  top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  width: 52px; height: 52px;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.5);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  border: 1.5px solid rgba(255, 255, 255, 0.2);
  opacity: 1;
}
.grid--work .play svg { width: 20px; height: 20px; }
@media (hover: hover) {
  .grid--work .card:hover .play { transform: translate(-50%, -50%) scale(1.12); }
}
.card__link { display: block; }
.card--case .card__media, .card--post .card__media { display: block; }
.card__title--lg { font-size: var(--fs-h4); font-weight: 600; letter-spacing: -0.03em; margin-top: 0.35rem; }
.card__excerpt { display: block; font-size: var(--fs-sm); color: var(--ink-mut); margin-top: 0.5rem; line-height: 1.55; }
.card__media { border: 0; padding: 0; width: 100%; }
button.card__media { cursor: pointer; }
.card--case .card__body .t-label { display: block; }
.grid-empty { color: var(--ink-mut); font-size: var(--fs-sm); padding-block: 2rem; }

/* ------------------------------------------------------------ case/post */

.post__head { padding-top: clamp(7rem, 5rem + 8vw, 11rem); padding-bottom: clamp(1.5rem, 1rem + 2vw, 2.5rem); }
.post__head .t-label a { color: inherit; }
.post__head .t-label a:hover { color: var(--primary); }
.post__head .post__meta { margin-top: 1rem; }
.post__hero { margin-bottom: clamp(2rem, 1rem + 3vw, 3.5rem); }
.post__hero img { width: 100%; border-radius: var(--radius-lg); }
.post__meta { font-size: var(--fs-sm); color: var(--ink-mut); }
.post__intro { font-size: var(--fs-h4); line-height: 1.4; letter-spacing: -0.03em; margin-bottom: 1rem; }

/* Case-study detail: back link + eyebrow + centered title, then the video
   gallery immediately below (the gallery is the hero on the real site, there
   is no separate hero image and no facts bar on the live pages, confirmed by
   direct inspection of cglabs.io/genworld/alpeire). Card/play/caption values
   are ported from the real CaseStudyDetail.tsx defaults. */
.case__head { padding-top: clamp(5rem, 4rem + 4vw, 7rem); padding-bottom: 0; text-align: center; }
.case__back-row { text-align: left; margin-bottom: clamp(1.5rem, 3vw, 2.5rem); }
.case__back { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.75rem; color: var(--ink); }
@media (hover: hover) { .case__back:hover { color: var(--primary); } }
.case__eyebrow { font-size: 1rem; font-weight: 700; color: var(--ink-faint); margin-bottom: 0.75rem; }
.case__title { font-size: clamp(1.75rem, 1.2rem + 2.6vw, 3.125rem); font-weight: 700; line-height: 1.2; max-width: 1060px; margin-inline: auto; }

.case__gallery {
  display: grid;
  gap: 14px;
  max-width: 1060px;
  margin: clamp(2rem, 3vw, 2.75rem) auto 0;
  grid-template-columns: repeat(var(--cs-cols, 1), 1fr);
}
.case__card {
  position: relative;
  display: block;
  overflow: hidden;
  cursor: pointer;
  background: #e2e1dc;
  border: 0;
  padding: 0;
  border-radius: 24px;
  transition: transform 0.25s var(--ease), box-shadow 0.25s var(--ease);
}
@media (hover: hover) {
  .case__card:hover { transform: translateY(-4px); box-shadow: 0 12px 40px rgba(0, 0, 0, 0.12); }
}
.case__aspect { display: block; position: relative; width: 100%; overflow: hidden; aspect-ratio: 9 / 16; }
.case__card--horizontal .case__aspect { aspect-ratio: 16 / 9; }
.case__thumb { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
.case__ph { position: absolute; inset: 0; }
.case__play {
  position: absolute;
  top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 52px; height: 52px;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.5);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  border: 1.5px solid rgba(255, 255, 255, 0.2);
  pointer-events: none;
  transition: transform 0.2s ease, opacity 0.2s ease;
}
.case__card--captioned .case__play { transform: translate(-50%, calc(-50% - 20px)); }
@media (hover: hover) {
  .case__card:hover .case__play { transform: translate(-50%, -50%) scale(1.15); }
  .case__card--captioned:hover .case__play { transform: translate(-50%, calc(-50% - 20px)) scale(1.15); }
}
.case__caption {
  position: absolute;
  bottom: 0; left: 0; right: 0;
  background: rgba(255, 255, 255, 0.88);
  backdrop-filter: blur(8px);
  -webkit-backdrop-filter: blur(8px);
  padding: 10px 14px;
  font-size: 11px;
  font-weight: 700;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: #6b6b63;
  text-align: center;
}
@media (max-width: 900px) {
  .case__gallery { grid-template-columns: repeat(min(var(--cs-cols, 1), 2), 1fr); padding-inline: 24px; }
}
@media (max-width: 580px) {
  .case__gallery { grid-template-columns: 1fr; padding-inline: 16px; }
}
.case__body { max-width: 1060px; margin: clamp(2.5rem, 4vw, 4rem) auto 0; }
/* .case__body already matches .case__gallery's 1060px box, but the global
   .prose rule caps its text at 68ch, so the copy stopped well short of the
   video row above it and the block read as misaligned. Only the case-study
   copy is released to the full container width; blog posts and the legal
   pages share .prose and keep the narrower measure. */
.case__body .prose { max-width: none; }

.post__nav { display: flex; justify-content: space-between; gap: 1.5rem; padding-block: clamp(2.5rem, 4vw, 4rem); border-top: 1px solid var(--line); margin-top: clamp(2.5rem, 4vw, 4rem); }
.post__nav-link { display: flex; flex-direction: column; gap: 0.3rem; font-size: var(--fs-sm); font-weight: 500; max-width: 22ch; }
.post__nav-link span { font-size: var(--fs-label); letter-spacing: var(--ls-label); text-transform: uppercase; color: var(--ink-faint); }
.post__nav-link--next { text-align: right; margin-left: auto; }
.post__nav-link:hover { color: var(--primary); }

/* -------------------------------------------------------------- lightbox */

.lightbox {
  position: fixed; inset: 0; z-index: 500;
  display: grid; place-items: center;
  padding: clamp(1rem, 4vw, 3rem);
  background: rgba(8,8,8,.92);
  backdrop-filter: blur(8px);
  animation: lb-in .3s var(--ease);
}
@keyframes lb-in { from { opacity: 0 } to { opacity: 1 } }
.lightbox__inner { position: relative; width: min(100%, 1200px); aspect-ratio: 16/9; }
.lightbox__inner iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; border-radius: var(--radius); }
/* 9:16 clips (case-study gallery cards) get a tall/narrow frame instead of
   the default wide one, matching the real site's per-clip lightbox sizing. */
/* The extra 160px of headroom is what keeps our close button off Vimeo's own
   controls. A 16:9 clip is short enough that centring it leaves a clear band
   at the top of the overlay, but a 9:16 clip is tall enough to reach the
   corner, and Vimeo puts its kebab menu in exactly the same place our X sits.
   Because the overlay centres its child, shrinking the video by 160px buys
   80px of clearance above it, which clears the 44px button plus its offset. */
.lightbox__inner--vertical {
  width: auto;
  height: min(85vh, 90vw * 16 / 9, calc(100dvh - 160px));
  max-width: 90vw;
  max-height: min(85vh, calc(100dvh - 160px));
  aspect-ratio: 9 / 16;
}
.lightbox__close {
  position: absolute; top: clamp(.75rem, 2vw, 1.5rem); right: clamp(.75rem, 2vw, 1.5rem);
  width: 44px; height: 44px; border-radius: 50%;
  background: rgba(255,255,255,.12); color: #fff; font-size: 1.6rem; line-height: 1;
  display: grid; place-items: center;
  /* Above the iframe. Without this the player can paint over the button and
     the only way out of the lightbox becomes unclickable. */
  z-index: 2;
  /* Solid enough to read against video, not just against the backdrop. */
  background: rgba(12, 12, 12, 0.66);
  border: 1px solid rgba(255, 255, 255, 0.18);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
}
/* Darker and a brighter rim on hover, rather than the old light fill, which
   would now invert the button against the solid dark base above. */
.lightbox__close:hover { background: rgba(12, 12, 12, 0.85); border-color: rgba(255, 255, 255, 0.4); }

/* Client/Agency info bar under the video, project-grid lightboxes only.
   Values measured directly off the real site's lightbox. */
.lightbox__info { margin-top: 20px; display: flex; flex-direction: column; align-items: center; gap: 10px; max-width: min(90vw, 720px); text-align: center; }
.lightbox__title { margin: 0; color: #fff; font-size: 0.95rem; font-weight: 600; letter-spacing: -0.01em; }
.lightbox__pills { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; }
.lightbox__pill {
  display: flex;
  align-items: center;
  gap: 5px;
  padding: 5px 12px;
  border-radius: 50px;
  background: rgba(255, 255, 255, 0.08);
  border: 1px solid rgba(255, 255, 255, 0.12);
}
.lightbox__pill-label { font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; color: rgba(255, 255, 255, 0.3); }
.lightbox__pill-value { font-size: 0.75rem; font-weight: 500; color: #fff; }

/* Mobile hero uses the vertical 9:16 cut, so it covers on portrait instead of pillarboxing. */
.hero__video--portrait { width: 100vw; height: 177.77vw; min-height: 100%; min-width: 100%; }

/* Logo wall. Brand names are not known per file, so the images are decorative
   (alt="") and the list carries the accessible name instead. */
/* Live site renders this as a ruled table, not a spaced grid: 6 columns of
   140x68 cells, no gap, 1px rgba(255,255,255,.08) hairlines (right on each
   cell, top on each row). Measured at 1280px. */
.brands__logos { display: grid; grid-template-columns: repeat(6, 1fr); gap: 0; list-style: none; }
.brands__logos li {
  min-height: 68px;
  display: flex; align-items: center; justify-content: center;
  padding: 0 0.75rem;
  border-top: 1px solid rgba(255, 255, 255, 0.08);
  border-right: 1px solid rgba(255, 255, 255, 0.08);
}
.brands__logos img { width: auto; max-width: 100%; max-height: 28px; object-fit: contain; opacity: .75; filter: brightness(0) invert(1); transition: opacity .3s var(--ease); }
.brands__logos img:hover { opacity: 1; }
@media (max-width: 900px) { .brands__logos { grid-template-columns: repeat(3, 1fr); } }
@media (max-width: 560px) { .brands__logos { grid-template-columns: repeat(2, 1fr); } }

/* ==========================================================================
   Proof section (heading then stats, matches live order)
   ========================================================================== */

.section--proof {
  padding-block: clamp(3.5rem, 2.5rem + 5vw, 7rem);
  position: relative;
  z-index: 1;
  background: var(--bg);   /* opaque: must fully cover the fixed hero video as it slides up */
  border-radius: 24px 24px 0 0;
  margin-top: -24px;   /* the rounded top begins revealing itself just before the hero's own scroll distance ends */
}
.section--proof .proof-heading {
  text-align: center;
  margin-bottom: clamp(2rem, 1rem + 3vw, 3.5rem);
  font-size: clamp(1.75rem, 1.2rem + 1.7vw, 2.4375rem); /* live: 39px at 1280, not 60 */
  letter-spacing: -0.07em;
}
.stat__label { border-top: 1px solid var(--line); padding-top: 0.85rem; margin-top: 0.85rem; }

/* ---------------------------------------------------------- service cards */

.service__media { margin: 10px 10px 0; border-radius: 16px; overflow: hidden; height: 394px; }
.service__media img { width: 100%; height: 100%; object-fit: cover; }

/* --------------------------------------------------- world card ornament */
/* Lightweight CSS stand-in for the canvas particle-portal effect behind the
   3D/GEN title on the live site (WorkHeroPortal / GenWorldBanner). Deliberate
   scope cut: nested rotating square frames instead of a full canvas port. */

.world { text-align: center; }
.world__ornament {
  position: absolute;
  top: 50%; left: 50%;
  width: clamp(90px, 14vw, 150px);
  height: clamp(90px, 14vw, 150px);
  transform: translate(-50%, -55%);
  pointer-events: none;
}
.world__ornament span {
  position: absolute;
  inset: 0;
  border: 1px solid rgba(255, 255, 255, 0.14);
  border-radius: 10px;
}
.world__ornament span:nth-child(1) { animation: world-spin 18s linear infinite; }
.world__ornament span:nth-child(2) { inset: 14%; animation: world-spin 14s linear infinite reverse; border-color: rgba(249, 69, 45, 0.22); }
.world__ornament span:nth-child(3) { inset: 28%; animation: world-spin 22s linear infinite; }
@keyframes world-spin { to { transform: rotate(360deg); } }
.world__title { position: relative; z-index: 1; }

@media (prefers-reduced-motion: reduce) {
  .world__ornament span { animation: none; }
}

/* ==========================================================================
   About page
   ========================================================================== */

/* Dark radial-pulse-grid band, ported from the real AboutHero.tsx code
   component (canvas in about-hero.js). Content sits in a flex row (title
   left, description right) over the canvas; both padding and the 600px
   mobile breakpoint match the component's own paddingTop/mobile logic
   exactly rather than this site's usual 900/700/580 breakpoints. */
/* padding-top clears the fixed header (measured 79px): the real component
   expects to sit below an in-flow header, but ours is position:fixed and
   takes up no flow space on its own, so without this the title starts under
   the header bar instead of below it. */
.about-hero { position: relative; overflow: hidden; background: #020303; padding-top: 79px; }
.about-hero__canvas { position: absolute; inset: 0; z-index: 0; width: 100%; height: 100%; }
.about-hero__inner {
  position: relative;
  z-index: 1;
  max-width: 1100px;
  padding: 52px 48px 48px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 48px;
}
.about-hero__title {
  flex-shrink: 0;
  font-family: var(--font-heading);
  font-weight: 700;
  font-size: clamp(3rem, 1.5rem + 6vw, 5rem);
  letter-spacing: -0.04em;
  line-height: 0.95;
  color: var(--white);
}
.about-hero__desc {
  flex-shrink: 0;
  max-width: 300px;
  text-align: right;
  font-size: 17px;
  line-height: 1.65;
  color: rgba(255, 255, 255, 0.65);
}
@media (max-width: 599px) {
  .about-hero__inner { max-width: none; padding: 36px 24px 32px; flex-direction: column; text-align: center; gap: 20px; }
  .about-hero__title { font-size: 36px; }
  .about-hero__desc { max-width: none; text-align: center; font-size: 13px; }
}

/* Founders: sticky 280px photo beside the bio, divider is the card's own
   border-bottom (not a separate <hr>), matching the real component. */
.founders { padding-block: 0 clamp(2rem, 1rem + 3vw, 4rem); }
.founders .wrap { max-width: 1100px; padding-inline: 48px; padding-block: 0 80px; }
.founder {
  display: grid;
  grid-template-columns: 280px 1fr;
  gap: 48px;
  align-items: start;
  padding-block: 48px 0;
  border-bottom: 1px solid #e5e5e5;
}
.founder[data-last] { border-bottom: none; }
.founder__photo {
  width: 100%;
  aspect-ratio: 3 / 4;
  border-radius: 16px;
  overflow: hidden;
  position: sticky;
  top: 100px;
}
.founder__photo img { width: 100%; height: 100%; object-fit: cover; }
.founder__bio { padding-top: 4px; }
.founder__name { font-family: var(--font-heading); font-size: 30px; font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; color: var(--ink); margin: 0 0 6px; }
.founder__role { font-size: 11px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; color: #a0a0a0; margin: 0 0 20px; }
.founder__bio p { color: #6b6b6b; font-size: 15px; line-height: 1.85; margin: 14px 0 0; }
.founder__bio p:first-of-type { margin-top: 0; }

@media (max-width: 599px) {
  .founders .wrap { padding-inline: 24px; padding-block: 0 48px; }
  .founder { grid-template-columns: none; display: flex; flex-direction: column; align-items: center; text-align: center; gap: 20px; padding-block: 32px 0; }
  .founder__photo { max-width: 220px; border-radius: 12px; position: static; }
  .founder__bio { padding-top: 0; }
  .founder__name { font-size: 24px; }
  .founder__role { margin-bottom: 16px; }
}

.pipeline { text-align: center; }
.pipeline .t-h2 { margin-bottom: 1rem; }
.pipeline .t-lead { max-width: 56ch; margin-inline: auto; }

/* ==========================================================================
   World listing pages (3dworld / genworld)
   ========================================================================== */

/* min-height matches the real site's fixed frame size (measured 680px desktop/
   tablet, 290px mobile, constant across widths, not content-driven) so the
   hero canvas gets the same amount of room to breathe as the source design. */
.world-hero {
  --world-hero-h: 680px;
  /* The header is position:fixed and overlays the top of this hero, so the
     composition must be centred in the band BELOW it, not in the full height.
     Centring on the raw height buried the top of the portal stack behind the
     header: 40px of 598px on desktop (7%, unnoticeable) but 63px of 255px on
     mobile (25%), which is why it read as cut off on a phone and fine on a
     laptop. world-hero.js measures the real header and publishes --hdr-h; the
     80px fallback is the computed height (1.15rem padding x2 + a 44px row) so
     this is still centred correctly with JS disabled. */
  --world-hero-inset: var(--hdr-h, 80px);
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  min-height: var(--world-hero-h);
  background: var(--bg-dark);
  color: var(--dk-ink);
  padding-block: clamp(3rem, 2rem + 4vw, 5rem);
  overflow: hidden;
  isolation: isolate;
}
@media (max-width: 900px) {
  .world-hero { --world-hero-h: 290px; }
}
/* Opaque sheet holding everything below the hero. Slides up over the pinned
   canvas and caps it with a rounded top edge, mirroring the homepage's
   .section--proof treatment over its fixed hero video.
   The negative margin means it also COVERS the last --world-sheet-overlap
   pixels of the hero, so the hero's usable band ends there, not at its full
   height. The hero composition is centred against that, which is why the
   variable is shared rather than repeated as a magic 24. */
.world-scroll {
  position: relative;
  z-index: 1;
  background: var(--bg);
  border-radius: var(--world-sheet-overlap) var(--world-sheet-overlap) 0 0;
  margin-top: calc(var(--world-sheet-overlap) * -1);
}
.world-hero__inner { position: relative; z-index: 1; text-align: center; }
/* .is-pinned is added by world-hero.js (never in the served HTML) so the text
   stays in normal flow without JS and under prefers-reduced-motion.
   The hero canvas is position:fixed and draws its frames centred on the hero's
   own height (CH/2 from the top of the viewport), so pinning the text the same
   way puts both on one compositor-driven layer: they hold together with zero
   jitter. Doing this positioning from a JS scroll handler instead is always a
   frame behind the real scroll offset (and fights Lenis's rAF loop), which is
   exactly what made the text shimmer. JS now only touches opacity. */
.world-hero__inner.is-pinned {
  position: fixed;
  /* Centre of the band below the header, matching the canvas (see the
     --world-hero-inset note above). Both must use the same expression or the
     title and the frames it sits inside drift apart. */
  top: calc(var(--world-hero-inset) + (var(--world-hero-h) - var(--world-sheet-overlap) - var(--world-hero-inset)) / 2);
  left: 0;
  right: 0;
  transform: translateY(-50%);
  /* Fades to 0 but stays in the layer tree, so it must never eat clicks meant
     for the content scrolling over it. Nothing in here is interactive. */
  pointer-events: none;
  will-change: opacity;
}
/* Per-world hero type. The two pages genuinely differ on the real site, so
   they can't share one rule. Measured at 1440 / 834 / 390 and expressed as a
   fluid clamp between the mobile and desktop anchors:
     Gen World  title  Outfit 700, 44 -> 180px, +0.04em
                sub    Montserrat 600, 11 -> 23px, +0.16em, 80% white
     3D World   title  heavy grotesque 900, 46 -> 160px, -0.03em
                sub    Outfit 500, 12 -> 21px, +0.2em, 71% white
   Line-height is 1 on both titles (real site sets it equal to the font size). */
.world-hero__title { line-height: 1; }
.world-hero--gen .world-hero__title {
  font-family: var(--font-outfit);
  font-weight: 700;
  font-size: clamp(2.75rem, -0.407rem + 12.952vw, 11.25rem);
  letter-spacing: 0.04em;
}
.world-hero--3d .world-hero__title {
  font-family: var(--font-sans);
  font-weight: 900;
  font-size: clamp(2.875rem, 0.229rem + 10.857vw, 10rem);
  letter-spacing: -0.03em;
}
.world-hero__subtitle { margin-top: 0.75rem; text-transform: uppercase; }
.world-hero--gen .world-hero__subtitle {
  font-family: var(--font-sans);
  font-weight: 600;
  font-size: clamp(0.6875rem, 0.029rem + 2.703vw, 1.4375rem);
  letter-spacing: 0.16em;
  color: rgba(255, 255, 255, 0.8);
}
.world-hero--3d .world-hero__subtitle {
  font-family: var(--font-outfit);
  font-weight: 500;
  font-size: clamp(0.75rem, 0.541rem + 0.857vw, 1.3125rem);
  letter-spacing: 0.2em;
  color: rgba(255, 255, 255, 0.71);
}
.section--dark.world-hero .t-accent,
.world-hero__subtitle { color: rgba(255, 255, 255, 0.35); }

/* Real interactive canvas (world-hero.js), ported from WorkHeroPortal /
   GenWorldBanner after discovering the spinning-square version above was a
   CSS stand-in built from a truncated extraction. */
/* Pinned to the viewport, not the hero box, so the content below scrolls up
   over it (same technique as the homepage's fixed hero video). The canvas is
   click-through; cursor tracking is handled on window in world-hero.js. */
.world-hero__canvas {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 0;
  pointer-events: none;
  width: 100%;
  /* Only the hero band is ever visible: below it the opaque .world-scroll sheet
     covers this canvas at every scroll position. It used to be inset:0, i.e.
     the full viewport, so every frame cleared and gradient-filled a surface
     roughly twice the size of the part anyone can see (53% waste at 1440p, 69%
     at 4K, at dpr 2). Nothing drawn here extends past the band, so clipping to
     it is free. min-height dominates the hero at both breakpoints, so this
     matches its real box. */
  height: var(--world-hero-h);
}

/* -------------------------------------------------------------- reel banner */

/* Verified against the live site: no watermark, no play button, it's a
   silent looping background strip. The container is much wider/shorter than
   16:9 (measured ~3.06:1 at desktop), so the video is rendered at its true
   16:9 (width:100%, aspect-ratio preserved) and the excess height is cropped
   by the container's overflow:hidden, not stretched/letterboxed like the
   previous version did. A centered "Play Reel" pill sits over the loop
   (measured on the real site: rgba(0,0,0,0.55) fill, 1px rgba(255,255,255,0.12)
   border, 12px blur, 8px radius, 15px/29px padding) and opens the full
   lightbox on click (confirmed cursor:pointer + click handler on the real
   site). */
/* 32px gap from the hero above, measured on the real site (hero bottom
   edge to reel-frame top): without it the video card's rounded corners
   touch the dark hero directly with no visible separation. */
.reel-banner { padding-top: 2rem; }
/* The gap UNDER the reel is not the banner's own padding, it is the following
   section's padding-top (.world-cases, 4.75rem), so the video sat in a lopsided
   band: 32px above, 76px below. Matched to the 2rem above so the card is evenly
   inset. Written as an adjacency so the intent stays legible (this spacing
   belongs to the reel, not to .world-cases) and so it keeps working if
   .world-cases is ever used without a reel above it. Today it never is: it
   appears only on 3dworld and genworld, always directly after .reel-banner.
   The 0,2,0 specificity clears the plain .world-cases rule whatever the
   source order. */
.reel-banner + .world-cases { padding-top: 2rem; }
.reel-banner__frame {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 21 / 7;
  border-radius: var(--radius-lg);
  overflow: hidden;
  background: var(--bg-dark);
  cursor: pointer;
}
/* The 16/9 video has to COVER the frame, and the frame is wider than 16/9 on
   desktop (21/7) but NARROWER than it on mobile (4/3). Driving off width alone
   only covers in the first case: at 4/3 the box is 0.750W tall while a 16/9
   video at width:100% is just 0.562W, leaving 25% of the box as black bars
   split top and bottom. So each breakpoint drives off whichever axis is the
   deficient one, and the other axis is left to aspect-ratio. */
.reel-banner__bg {
  position: absolute;
  top: 50%; left: 50%;
  width: 100%;
  height: auto;
  aspect-ratio: 16 / 9;
  transform: translate(-50%, -50%);
  border: 0;
  pointer-events: none;
}
.reel-banner__play {
  position: absolute;
  top: 50%; left: 50%;
  transform: translate(-50%, -50%);
  z-index: 1;
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 15px 29px;
  border-radius: 8px;
  border: 1px solid rgba(255, 255, 255, 0.12);
  background: rgba(0, 0, 0, 0.55);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  color: var(--white);
  font-size: 17px;
  font-weight: 500;
  white-space: nowrap;
  transition: background-color 0.25s var(--ease), transform 0.25s var(--ease);
}
@media (hover: hover) {
  .reel-banner__frame:hover .reel-banner__play {
    background: rgba(0, 0, 0, 0.72);
    transform: translate(-50%, -50%) scale(1.04);
  }
}
@media (max-width: 700px) {
  .reel-banner__frame { aspect-ratio: 4 / 3; }
  /* 4/3 is narrower than 16/9, so height is the deficient axis here and the
     video has to be driven off it. The width is stated explicitly rather than
     left to `width:auto` + aspect-ratio: an iframe is a REPLACED element, so
     auto resolves to its intrinsic 300px default instead of height x 16/9, and
     the black bars simply stayed. (The opposite direction, definite width ->
     height from the ratio, does work, which is why desktop was always fine.)
     Frame is 4/3, so its height is 0.75w and the covering video must be
     0.75w x 16/9 = 4/3 of the frame width. overflow:hidden crops the sides. */
  /* max-width:none is load-bearing. The global `img, video, svg, iframe`
     reset sets max-width:100%, which silently clamped the 133.33% width back
     to the frame's own width. The iframe then sat at 4/3 rather than 16/9 and
     the Vimeo player letterboxed the video inside it, which is the exact black
     bars this block exists to remove. Verified in the browser: without it the
     iframe measures 350x262.5 (4/3), with it 466.7x262.5 (16/9). */
  .reel-banner__bg {
    width: calc(100% * 4 / 3);
    max-width: none;
    height: 100%;
    aspect-ratio: auto;
  }
  .reel-banner__play { padding: 12px 22px; font-size: 15px; }
}

/* ------------------------------------------------------- case card overlay */

.card--case .card__media { position: relative; }
.card__scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, rgba(0,0,0,0) 40%, rgba(0,0,0,.7) 100%);
}
.card__overlay {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  padding: clamp(1rem, 0.7rem + 1vw, 1.5rem);
  color: var(--white);
}
.card__overlay .t-label { color: rgba(255,255,255,.6); margin-bottom: 0.3rem; display: block; }
.card--case .card__title--lg { color: var(--white); }

/* "Case study" pill above each card title. Sits on the artwork rather than the
   scrim alone, so it carries its own translucent fill and hairline to stay
   readable over a bright frame as well as a dark one. Text is full white on a
   fill that is always composited over the scrim's dark base, which keeps it
   well clear of the 4.5:1 floor at this size. */
.card__badge {
  display: inline-block;
  margin-bottom: 0.5rem;
  padding: 4px 10px;
  border-radius: 999px;
  background: rgba(12, 12, 12, 0.55);
  border: 1px solid rgba(255, 255, 255, 0.22);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  color: var(--white);
  font-size: 10px;
  font-weight: 600;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  line-height: 1.4;
}

/* ---------------------------------------------------------------- pager */

.pager { display: flex; flex-direction: column; align-items: center; gap: 0.75rem; margin-top: clamp(2rem, 1rem + 3vw, 3rem); }
.pager__status { font-size: var(--fs-sm); color: var(--ink-mut); }
.pager__controls { display: flex; align-items: center; gap: 1rem; }
.pager__nav {
  width: 40px; height: 40px; border-radius: 50%;
  border: 1px solid var(--line);
  display: grid; place-items: center;
  font-size: 1.25rem;
  transition: border-color .3s var(--ease), background-color .3s var(--ease);
}
.pager__nav:disabled { opacity: 0.35; pointer-events: none; }
@media (hover: hover) { .pager__nav:not(:disabled):hover { border-color: var(--ink); } }
.pager__dots { display: flex; gap: 0.4rem; }
.pager__dots li {
  width: 7px; height: 7px; border-radius: 50%;
  background: var(--line);
  cursor: pointer;
  transition: background-color .3s var(--ease), transform .3s var(--ease);
}
.pager__dots li.is-active { background: var(--primary); transform: scale(1.3); }

/* ==========================================================================
   Blog index
   ========================================================================== */

.blog-hero { padding-top: clamp(6rem, 4.5rem + 6vw, 9rem); padding-bottom: clamp(2.5rem, 1.5rem + 3vw, 4rem); }
.blog-hero__title {
  font-family: var(--font-bebas);
  font-weight: 400;
  font-size: clamp(2.75rem, 1.8rem + 4.5vw, 5.5rem);
  letter-spacing: 0.01em;
  color: #8FE3D1; /* approximation, exact brand teal not extracted from CMS tokens */
  line-height: 1;
  margin-bottom: 0.6rem;
}

.post-list { border-top: 1px solid var(--line); }
.post-row { border-bottom: 1px solid var(--line); }
.post-row__link {
  display: flex;
  align-items: center;
  gap: clamp(1rem, 0.6rem + 1.5vw, 1.75rem);
  padding-block: clamp(1rem, 0.7rem + 1vw, 1.5rem);
}
.post-row__media { flex: 0 0 auto; width: clamp(100px, 12vw, 160px); border-radius: 8px; overflow: hidden; aspect-ratio: 10/7; }
.post-row__media img { width: 100%; height: 100%; object-fit: cover; }
.post-row__body { display: flex; flex-direction: column; gap: 0.3rem; min-width: 0; }
.post-row__title { font-size: var(--fs-lg); font-weight: 600; letter-spacing: -0.02em; color: var(--ink); transition: color .3s var(--ease); }
.post-row__excerpt { font-size: var(--fs-sm); color: var(--ink-mut); line-height: 1.5; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
@media (hover: hover) { .post-row__link:hover .post-row__title { color: var(--primary); } }

@media (max-width: 560px) {
  .post-row__link { align-items: flex-start; }
  .post-row__media { width: 90px; }
  .post-row__excerpt { -webkit-line-clamp: 2; }
}

/* ==========================================================================
   Blog post detail
   ========================================================================== */

/* The author byline used to sit above the title in .post__hero-top, and that
   element carried the top padding that clears the fixed header. With the
   byline gone the padding has to live on the hero itself, or the title rides
   up underneath the header. The hero image and scrim are both absolutely
   positioned to inset:0, so padding here moves the text without cropping the
   artwork. */
.post__hero {
  position: relative;
  display: block;
  min-height: clamp(320px, 42vw, 520px);
  padding-top: clamp(6rem, 4.5rem + 6vw, 9rem);
  background: var(--bg-dark);
  overflow: hidden;
  color: var(--white);
}
.post__hero-img { position: absolute; inset: 0; width: 100%; height: 100%; }
.post__hero-img img { width: 100%; height: 100%; object-fit: cover; }
.post__hero-scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg, rgba(0,0,0,.55) 0%, rgba(0,0,0,.15) 35%, rgba(0,0,0,.75) 100%);
}
/* .post__hero-top and the .post__author-* rules lived here. Blog posts no
   longer carry a byline, so they are removed rather than left as dead weight.
   The author fields are still present in content/articles.json, so restoring a
   byline is a markup change, not a re-import. */
.post__hero-bottom { position: relative; z-index: 1; padding-block: clamp(2.5rem, 2rem + 4vw, 5rem) clamp(2rem, 1.5rem + 2vw, 3rem); }
.post__hero-title { font-size: clamp(2rem, 1.3rem + 3.5vw, 3.75rem); letter-spacing: -0.04em; line-height: 1.05; max-width: 22ch; }
.post__hero-desc { margin-top: 0.85rem; font-size: var(--fs-body); color: rgba(255,255,255,.7); max-width: 50ch; }

/* max-width caps the rail+content pair as one unit and centers it on the
   page. Without this, on wide screens the rail sits pinned at the far-left
   gutter while the text centers only within its own (very wide) grid
   column, leaving a large dead zone on the right that has nothing to do
   with the text's own reading width. */
.post__layout {
  display: grid;
  grid-template-columns: minmax(160px, 220px) 1fr;
  gap: clamp(2rem, 1rem + 3vw, 4rem);
  align-items: start;
  padding-block: clamp(2.5rem, 1.5rem + 3vw, 4rem);
  max-width: 1160px;
  margin-inline: auto;
}
.post__rail { position: sticky; top: 6rem; }
.post__back { display: inline-flex; align-items: center; gap: 0.4rem; font-size: var(--fs-sm); font-weight: 500; color: var(--ink-mut); }
.post__back:hover { color: var(--primary); }
/* max-width caps line length for readability, margin-inline centers it
   within the (wide, 1fr) grid column instead of leaving it flush-left with
   all the leftover space dumped on the right on wide screens. */
.post__content { max-width: 860px; margin-inline: auto; }
.post__intro { font-size: var(--fs-h4); line-height: 1.4; letter-spacing: -0.03em; font-weight: 600; margin-bottom: 1rem; }

.post__more { border-top: 1px solid var(--line); }
.post__more-heading { font-size: var(--fs-h3); color: var(--ink-faint); }
.post__more-sub { font-size: var(--fs-sm); color: var(--ink-mut); margin-top: 0.5rem; max-width: 26ch; }
.post-list--more { border-top: none; }
.post-row__link--flat { display: flex; gap: 1.5rem; padding-block: 0.9rem; align-items: baseline; }
.post-row__date { flex: 0 0 auto; width: 7rem; font-size: var(--fs-xs); color: var(--ink-faint); }
.post-row--extra.post-row { display: list-item; }
.post__load-more {
  display: inline-flex; align-items: center; gap: 0.5rem;
  margin-top: 1.5rem; font-size: var(--fs-sm); font-weight: 600; color: var(--primary);
}
.post__load-more:hover { text-decoration: underline; }

@media (max-width: 800px) {
  .post__layout { grid-template-columns: 1fr; }
  .post__rail { position: static; }
  .post__more .post__rail { margin-bottom: 1rem; }
}

/* The dark brands band is NOT on the site-wide --sec-y rhythm. Live measures a
   flat 60px top and bottom (section height 359px); the generic clamp was giving
   116.8px and nearly doubling the band's height. */
.section--brands { padding-block: 60px; }

@media (max-width: 900px) {
  .brands { grid-template-columns: 1fr; gap: 2.5rem; }
  .section--brands { padding-block: 44px; }
  /* The hard line break only helps at the desktop column width. */
  .brands__intro h2 br { display: none; }
}

/* Live logo cells measure 137x68; pinning the row height keeps the dark band
   at the live 359px total instead of drifting taller. */
.brands__logos li { min-height: 68px; }

/* The brands band and the worlds band escape the 1848px cap and run full-bleed.
   Verified at 2000px: the brands logo grid sits at x=392 with width 1545, wider
   than a centred 1848px container would allow. */
.section--brands .wrap,
.section--worlds .wrap { max-width: none; }

/* ==========================================================================
   Brands band, centered layout
   One consolidated ruleset (replaces three overlapping iterations: an
   original left/right split, then a centered redesign, then a narrower
   variant, each overriding the last by source order). That layering left a
   stray `flex: 1 1 22ch` on .t-lead from the first iteration, which nobody
   ever removed; in the final column layout it made the paragraph flex-grow
   to fill the section's leftover height, ~150px of invisible box below two
   lines of visible text; read as "too much dead air" because it was
   literally an empty, unintentional box. Every property below is set
   explicitly on a single 8px-based scale so nothing is inherited from a
   dead iteration.
   ========================================================================== */

.section--brands.is-stacked .brands {
  grid-template-columns: 1fr;
  gap: 32px;
}

/* Above 900px: one horizontal row (heading | subtext | stats), each segment
   separated by a hairline, vertically centered on each other. The ROW is
   centered as a group (justify-content:center on a full-width flex parent),
   not stretched edge to edge, so it reads as one deliberate composed unit
   rather than three independent centered blocks stacked on top of each other. */
.section--brands.is-stacked .brands__intro {
  max-width: none;
  margin-inline: auto;
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  text-align: left;
  gap: 28px;
}
.section--brands.is-stacked .brands__intro h2 {
  flex: 0 0 auto;
  white-space: nowrap;
}
.section--brands.is-stacked .brands__intro h2 br { display: none; }

.section--brands.is-stacked .brands__intro .t-lead {
  flex: 0 1 30ch;
  max-width: 30ch !important;
  margin: 0;
  padding-left: 28px;
  border-left: 1px solid rgba(255, 255, 255, 0.14);
  text-align: left;
}

.section--brands.is-stacked .brands__stats {
  flex: 0 0 auto;
  margin: 0;
  padding-left: 28px;
  border-left: 1px solid rgba(255, 255, 255, 0.14);
  justify-content: flex-start;
  gap: 28px;
}

/* Below 900px there isn't room for a row without cramming or wrapping badly
   mid-sentence, so it drops back to the centered column that was already
   verified correct (heading, subtext, stats stacked, each centered). */
@media (max-width: 900px) {
  .section--brands.is-stacked .brands__intro {
    flex-direction: column;
    text-align: center;
    gap: 12px;
  }
  .section--brands.is-stacked .brands__intro h2 { white-space: normal; }
  .section--brands.is-stacked .brands__intro .t-lead {
    flex: 0 0 auto;
    max-width: 42ch !important;
    margin: 0 auto;
    padding-left: 0;
    border-left: 0;
    text-align: center;
  }
  .section--brands.is-stacked .brands__stats {
    margin: 8px auto 0;
    padding-left: 0;
    border-left: 0;
    justify-content: center;
  }
  .section--brands.is-stacked .brands__intro { max-width: 440px; }
  .section--brands.is-stacked .brands__logos { grid-template-columns: repeat(3, 1fr); }
}

/* No border-top here. Every cell already draws its own hairline (see
   .brands__logos li), so the first row of cells IS the top rule of the table.
   A second line above it, separated by 32px of padding, read as a stray
   divider floating over the grid rather than part of it. Dropping it also
   closes the gap: the logos now sit 20px under the intro instead of 64px. */
.section--brands.is-stacked .brands__logos {
  grid-template-columns: repeat(6, 1fr);
  max-width: 1180px;
  margin: 20px auto 0;
  border-top: 0;
  padding-top: 0;
}
.section--brands.is-stacked .brands__logos img { max-height: 38px; opacity: .88; }

/* ---- cursor-proximity lighting (assets/js/brands-wall.js) ----
   The wall used to react only through `img:hover`, which had two problems: the
   hover target was the logo's own bounds rather than the cell, so most of each
   cell was dead space, and .88 -> 1 opacity is a change almost nobody notices.
   The whole treatment lives inside @media (hover: hover) on purpose. --near is
   only ever set by a real pointer, so on phones it would stay 0 forever and any
   resting style expressed through it would be stuck at its dimmest value. Touch
   devices therefore keep the plain .88 rule above, unchanged. */
@media (hover: hover) {
  .section--brands.is-stacked .brands__logos { position: relative; }

  .section--brands.is-stacked .brands__logos li {
    --near: 0;
    /* Lit cells wash and their hairlines lift, so the grid structure itself
       responds rather than just the artwork sitting in it. */
    background-color: rgba(255, 255, 255, calc(var(--near) * 0.07));
    border-top-color: rgba(255, 255, 255, calc(0.1 + var(--near) * 0.16));
    border-right-color: rgba(255, 255, 255, calc(0.1 + var(--near) * 0.16));
    transition: background-color 0.3s var(--ease), border-color 0.3s var(--ease);
  }

  /* Resting opacity is only pulled from .88 to .82, not further. This is the
     client-credibility section, and a wall of logos sitting visibly dimmer than
     before whenever the cursor is elsewhere is a real cost to pay for an effect
     most visitors will not trigger. The interaction is carried mainly by the
     cell instead: its wash and hairlines go from nothing to clearly visible,
     which is a far larger relative change than any opacity step on the artwork.
     The scale is small on purpose too. These are other companies' trademarks,
     and bouncing them around would cheapen the section. */
  .section--brands.is-stacked .brands__logos img {
    opacity: calc(0.82 + var(--near) * 0.18);
    transform: scale(calc(1 + var(--near) * 0.035));
    transition: opacity 0.3s var(--ease), transform 0.3s var(--ease);
    will-change: opacity, transform;
  }

  /* The cell under the cursor wins outright, so there is still a definite
     "this one" state on top of the ambient falloff. Bound to the CELL, which
     is the fix for the dead-space problem above. */
  .section--brands.is-stacked .brands__logos li:hover img { opacity: 1; }

  @media (prefers-reduced-motion: reduce) {
    .section--brands.is-stacked .brands__logos li,
    .section--brands.is-stacked .brands__logos img { transition: none; }
    /* brands-wall.js never runs under reduced motion, so --near stays 0.
       Restore the static resting opacity rather than leaving it at 0.72. */
    .section--brands.is-stacked .brands__logos img { opacity: .88; transform: none; }
    .section--brands.is-stacked .brands__logos li:hover img { opacity: 1; }
  }
}

@media (max-width: 560px) {
  .section--brands.is-stacked .brands__logos { grid-template-columns: repeat(2, 1fr); }
}

/* The dark-mode world-page overrides that used to live here were removed along
   with the rest of dark mode; see the note where the main block was. */

/* ==========================================================================
   Join / apply  (/join and /join/generative)
   Deliberately narrow: a 21-field form needs a short measure or the eye loses
   the line between label and input. Everything reuses the existing tokens so
   the page reads as part of the site rather than an embedded form service.
   ========================================================================== */

.jn { padding-block: clamp(6rem, 4rem + 6vw, 9rem) clamp(4rem, 3rem + 4vw, 7rem); }
.jn__wrap { max-width: 720px; }
.jn__eyebrow { color: var(--ink-mut); margin-bottom: 1rem; }
.jn__eyebrow a { color: var(--ink-mut); text-decoration: none; border-bottom: 1px solid var(--line); }
.jn__eyebrow a:hover { color: var(--ink); }
.jn__title { font-size: var(--fs-h2); line-height: 1.02; letter-spacing: -0.02em; margin: 0 0 1rem; text-wrap: balance; }
.jn__intro { font-size: var(--fs-lg); color: var(--ink-2); margin: 0 0 clamp(2.5rem, 2rem + 2vw, 3.5rem); max-width: 60ch; }

/* ------------------------------------------------------------- role cards */

.jn__cards { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }
@media (max-width: 700px) { .jn__cards { grid-template-columns: 1fr; } }

.jn__card {
  display: flex; flex-direction: column; gap: 0.6rem;
  padding: clamp(1.5rem, 1.2rem + 1vw, 2rem);
  border: 1px solid var(--line); border-radius: var(--radius-lg);
  background: var(--bg); color: var(--ink); text-decoration: none;
  transition: transform .5s var(--ease), border-color .3s var(--ease), box-shadow .5s var(--ease);
}
a.jn__card:hover {
  transform: translateY(-3px);
  border-color: rgba(12, 12, 12, 0.28);
  box-shadow: 0 1px 2px rgba(12,12,12,.05), 0 16px 34px rgba(12,12,12,.08);
}
a.jn__card:focus-visible { outline: 2px solid var(--primary-cta); outline-offset: 3px; }
.jn__card-label { font-size: var(--fs-h4); font-weight: 600; letter-spacing: -0.02em; }
.jn__card-blurb { font-size: var(--fs-sm); color: var(--ink-mut); }
.jn__card-cta { margin-top: 0.4rem; font-size: var(--fs-sm); font-weight: 600; color: var(--primary-cta); }

/* The unopened role reads as inert on purpose: same card, muted, no lift. */
.jn__card--soon { background: var(--bg-alt); }
.jn__card--soon .jn__card-label,
.jn__card--soon .jn__card-blurb { color: var(--ink-mut); }
.jn__card--soon .jn__card-cta { color: var(--ink-mut); }

/* ------------------------------------------------------------------- form */

.jn__form { display: flex; flex-direction: column; gap: clamp(2.75rem, 2.2rem + 2vw, 4rem); }
/* fieldset carries a default border and padding in every browser */
.jn__section { border: 0; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 2rem; }
.jn__legend {
  padding: 0; margin-bottom: 1.35rem;
  font-size: var(--fs-label); letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--ink-mut);
}
.jn__field { display: flex; flex-direction: column; gap: 0.6rem; border: 0; padding: 0; margin: 0; }
.jn__label { font-size: var(--fs-sm); font-weight: 600; letter-spacing: -0.01em; padding: 0; }
.jn__req { color: var(--primary-cta); }
.jn__hint { font-size: var(--fs-xs); color: var(--ink-mut); margin: 0; }

.jn__input {
  width: 100%; font: inherit; font-size: var(--fs-body); color: var(--ink);
  background: var(--bg); border: 1px solid var(--line); border-radius: var(--radius);
  padding: 0.7rem 0.85rem;
  transition: border-color .2s var(--ease), box-shadow .2s var(--ease);
}
.jn__input::placeholder { color: var(--ink-faint); }
.jn__input:hover { border-color: rgba(12, 12, 12, 0.24); }
.jn__input:focus {
  outline: none; border-color: var(--ink);
  box-shadow: 0 0 0 3px rgba(12, 12, 12, 0.06);
}
.jn__textarea { resize: vertical; min-height: 7rem; line-height: 1.55; }
/* Native select keeps its own arrow on some platforms and loses it on others,
   so the chevron is drawn here and the native one suppressed. */
.jn__select {
  appearance: none; -webkit-appearance: none; padding-right: 2.4rem;
  background-image: url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%230c0c0c' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat; background-position: right 0.9rem center; background-size: 12px 8px;
}

/* --------------------------------------------------------- radio / checkbox */

.jn__opts { display: flex; flex-wrap: wrap; gap: 0.65rem 0.5rem; }
.jn__opts--checkbox { gap: 0.65rem 0.5rem; }
.jn__opt { position: relative; }
.jn__opt input {
  position: absolute; inset: 0; width: 100%; height: 100%;
  margin: 0; opacity: 0; cursor: pointer;
}
.jn__opt label {
  display: block; cursor: pointer;
  font-size: var(--fs-sm); color: var(--ink-2);
  border: 1px solid var(--line); border-radius: 999px;
  padding: 0.52rem 0.95rem; background: var(--bg);
  transition: border-color .2s var(--ease), background .2s var(--ease), color .2s var(--ease);
}
.jn__opt input:hover + label { border-color: rgba(12, 12, 12, 0.28); }
.jn__opt input:checked + label {
  background: var(--ink); border-color: var(--ink); color: #fff;
}
/* :focus-visible cannot reach the label from a visually hidden input, so the
   ring is drawn on the label via the input's focus state. */
.jn__opt input:focus-visible + label { outline: 2px solid var(--primary-cta); outline-offset: 2px; }

/* --------------------------------------------------------------- feedback */

.jn__field--bad .jn__input { border-color: var(--primary-cta); }
.jn__field--bad .jn__label { color: var(--primary-cta); }

.jn__actions { display: flex; align-items: center; gap: 1.25rem; flex-wrap: wrap; }
.jn__submit {
  font: inherit; font-size: var(--fs-sm); font-weight: 600; letter-spacing: -0.01em;
  color: #fff; background: var(--ink); border: 0; border-radius: 999px;
  padding: 0.85rem 1.8rem; cursor: pointer;
  transition: background .25s var(--ease), transform .25s var(--ease);
}
.jn__submit:hover { background: var(--primary-cta); }
.jn__submit:focus-visible { outline: 2px solid var(--primary-cta); outline-offset: 3px; }
.jn__submit:disabled { opacity: 0.6; cursor: default; background: var(--ink); }
.jn__note { font-size: var(--fs-xs); color: var(--ink-mut); margin: 0; }
.jn__status { font-size: var(--fs-sm); color: var(--ink-2); margin: 0; }
.jn__status--bad { color: var(--primary-cta); }

/* Honeypot. Off-screen rather than display:none, because some bots skip fields
   that are not rendered at all. */
.jn__botcheck { position: absolute !important; left: -9999px !important; opacity: 0 !important; }

.jn__done {
  border: 1px solid var(--line); border-radius: var(--radius-lg);
  background: var(--bg-alt); padding: clamp(2rem, 1.6rem + 1.6vw, 3rem);
}
.jn__done:focus { outline: none; }
.jn__done-title { font-size: var(--fs-h3); letter-spacing: -0.02em; margin: 0 0 0.6rem; }
.jn__done-body { color: var(--ink-2); margin: 0; max-width: 52ch; }

/* ---- application page: particle field + raised form card ---------------- */

/* Fixed and behind everything. pointer-events:none matters: without it the
   canvas would swallow every click meant for the 21 fields sitting on top. */
.jn__canvas {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  pointer-events: none;
  /* Transparent: the field clears each frame and the page ground supplies the
     white, so the canvas never paints a colour of its own. */
  background: transparent;
}
/* The page ground is white so the canvas trail (which paints white) blends
   into it seamlessly rather than showing a seam at the canvas edge. */
body:has(.jn__canvas) { background: #fff; }
.jn--form { position: relative; z-index: 1; background: transparent; overflow-x: clip; }

/* The heading sits directly on the particle field; only the form itself gets
   a card, so the field stays visible around and above it. */
.jn--form .jn__form,
.jn--form .jn__done {
  position: relative;
  background: #faf9f8;                     /* slightly off-white, as asked */
  border: 1px solid var(--line);
  border-radius: var(--radius-lg);
  padding: clamp(1.75rem, 1.4rem + 1.8vw, 2.75rem);
  /* Lifts the card off the moving field. Without the shadow the two planes
     read as one surface and the particles look like noise ON the form. */
  box-shadow: 0 1px 2px rgba(12, 12, 12, .04), 0 18px 44px rgba(12, 12, 12, .07);
}
/* Inputs go white so they still separate from the off-white card. */
.jn--form .jn__input,
.jn--form .jn__opt label { background: #fff; }

/* A soft white veil under the header block, so the title and intro stay
   readable wherever a dense clump of particles happens to drift. */
.jn--form .jn__wrap > .jn__eyebrow,
.jn--form .jn__wrap > .jn__title,
.jn--form .jn__wrap > .jn__intro { position: relative; z-index: 1; }
.jn--form .jn__wrap::before {
  content: "";
  position: absolute;
  /* Inset to 0, never negative. At -8% each side the pseudo-element made the
     document wider than the viewport on mobile, which pushed the intro copy
     and the form card off the right edge. The gradient is soft enough that it
     does not need to overhang to avoid a visible edge. */
  left: 0; right: 0; top: -6%;
  height: 430px;
  background: radial-gradient(62% 74% at 46% 42%, rgba(255,255,255,.97) 0%, rgba(255,255,255,.9) 40%, rgba(255,255,255,.55) 70%, rgba(255,255,255,0) 100%);
  pointer-events: none;
  z-index: 0;
}
.jn--form .jn__wrap { position: relative; }

@media (prefers-reduced-motion: reduce) {
  .jn__canvas { opacity: .5; }
}

/* Paired controls on one line. `auto` on the second column lets the currency
   select size to its own content rather than taking half the row, so the rate
   field gets the space it actually needs. */
/* Flex rather than a fixed grid: the row holds two controls on one form and
   three on another, and flex lets the amount field take the slack while the
   selects size to their own content. */
.jn__row { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: start; }
.jn__row > * { flex: 0 0 auto; }
.jn__row > .jn__field--grow { flex: 1 1 7rem; min-width: 6rem; }
.jn__row .jn__select { min-width: 7.5rem; }
@media (max-width: 560px) {
  /* Stack on a phone: three controls side by side leaves the amount field a
     few characters wide. */
  .jn__row { display: block; }
  .jn__row > * + * { margin-top: 1.25rem; }
  .jn__row > .jn__field--grow { min-width: 0; }
}

/* A <legend> is laid out outside its fieldset's flex flow, so the .jn__field
   `gap` that separates a normal label from its input never reaches it: the pill
   groups had their options sitting flush under the question. This margin is the
   only separation a legend gets, same trap as .jn__legend above. Slightly more
   than the 0.6rem flex gap because a wrapped pill row reads as a denser block
   than a single input. */
.jn__field--group > .jn__label { margin-bottom: 0.8rem; }
/* The hint, when present, sits between them and supplies its own spacing. */
.jn__field--group > .jn__label + .jn__hint { margin-top: -0.4rem; margin-bottom: 0.55rem; }
