/* ═══ SILKA MONO (12 Sep 2026, Haseeb: "change my mono font to silka mono") ═══
   His licensed family, self-hosted: the TTF webfonts off his machine converted
   to woff2 (80KB → 24KB each). Two weights, because that is what --mono is used
   at across the site — 400 for the overlines and meta, 700 for the labels.
   swap: the mono is furniture (labels, clocks, the header word), so a system
   mono for the first frames costs nothing and a blocked paint would cost a lot.
   Space Mono stays in the stack behind it as the fallback it always was. */
@font-face{
  font-family:'Silka Mono';
  src:url('/assets/fonts/SilkaMono-Regular.woff2') format('woff2');
  font-weight:400;font-style:normal;font-display:swap;
}
@font-face{
  font-family:'Silka Mono';
  src:url('/assets/fonts/SilkaMono-Bold.woff2') format('woff2');
  font-weight:700;font-style:normal;font-display:swap;
}
@font-face{
  font-family:'Silka Mono';
  src:url('/assets/fonts/SilkaMono-Medium.woff2') format('woff2');
  font-weight:500;font-style:normal;font-display:swap;   /* the buttons' weight (12 Sep) */
}
@font-face{
  font-family:'Silka Mono';
  src:url('/assets/fonts/SilkaMono-SemiBold.woff2') format('woff2');
  font-weight:600;font-style:normal;font-display:swap;   /* 12 Sep: the buttons went one weight heavier */
}

/* ═══════════════════════════════════════════════════════════════════════════
   rzv.studio — base.css  (FOUNDATION, owns tokens + every shared surface)

   Everything here is lifted from the rzv dashboard so the two products share
   one material language. Source of each recipe is credited in the comment
   above it — go read the original before changing a number.

   Load order on every page:   base.css → intro.css → constellation.css →
   work.css   (section stylesheets may override, never re-declare tokens).

   TWO TRAPS, obeyed throughout:
   1. Never `backdrop-filter` on an ancestor of a `mix-blend-mode` child —
      iOS Safari isolates the blend group against transparent-black and paints
      a solid black box. The ≤760px block at the bottom of this file strips
      the blur from every glass surface and drops the blends with it.
   2. GSAP owns `transform` on tiles / the pearl / mesh blooms. `transform` is
      deliberately ABSENT from those elements' CSS transition lists — a CSS
      transition on a property GSAP writes every frame smears each frame into
      the next.
   ═══════════════════════════════════════════════════════════════════════ */

/* ── Tokens (BRIEF.md §Design tokens — dashboard DNA, focus.html :root) ──── */
:root{
  /* Tells the UA which way the built-in widgets, scrollbars and form controls
     should render. Without it a dark page gets light scrollbars. */
  color-scheme:dark;

  --bg:#08080A; --bg-2:#0C0C10;
  --surface:rgba(255,255,255,0.025); --surface-2:rgba(255,255,255,0.045); --surface-hi:rgba(255,255,255,0.07);
  --border:rgba(255,255,255,0.06); --border-hi:rgba(255,255,255,0.13);
  --text:#FAFAFA; --muted:rgba(250,250,250,0.5); --dim:rgba(250,250,250,0.28); --faint:rgba(250,250,250,0.1);
  --card:rgba(22,22,28,0.58); --card-hi:rgba(30,30,38,0.68); --tile-sheen:rgba(255,255,255,0.07);
  --ease:cubic-bezier(0.16,1,0.3,1);            /* house curve (expo.out) */
  --ease-spring:cubic-bezier(0.34,1.56,0.64,1); /* overshoot */
  --mono:'Silka Mono','Space Mono',ui-monospace,SFMono-Regular,Menlo,monospace;

  /* Semantic pair, dashboard names (focus.html --done/--over, portal.css
     --ptl-pos/--ptl-neg). Nothing on this site is a status readout yet; they
     exist so the first thing that needs one does not invent its own green. */
  --pos:#4ADE80; --neg:#F87171;

  /* The page aura wash — focus.html:63. One very soft ellipse bled off the top
     edge, which is what stops a flat #08080A field reading as a black
     rectangle. Painted by body::before below. */
  --aura:rgba(167,139,250,0.05);

  /* Aurora palette — the six, in order. Named so JS can read them off :root. */
  --a1:#4F46E5; --a2:#818CF8; --a3:#22D3EE; --a4:#A78BFA; --a5:#F472B6; --a6:#6366F1;

  /* Radii scale (BRIEF §Design tokens) */
  --r-tile:24px; --r-modal:28px; --r-card:14px; --r-pill:100px;

  /* Rhythm */
  /* --vh: the LARGE viewport, as a static token. Every full-screen box is
     calc(var(--vh)*100): on a phone that is the screen with the toolbar
     retracted, so the frame FILLS it once you scroll and never changes size —
     the open toolbar just covers the bottom of it. (A measured innerHeight
     was tried: it sizes to the SMALL viewport and leaves a dead strip under
     the corners the moment the toolbar goes.) */
  --vh:1vh;
  --wrap:1360px;
  --wrap-hero:1760px;   /* header row + hero corners: edge to edge on a MacBook (1440/1512 + gutter), capped past that */
  --gutter:clamp(20px,4vw,56px);
  --section-y:clamp(96px,14vh,180px);
  --header-h:72px;

  /* ── TYPE SCALE (9 Sep 2026 sweep) ────────────────────────────────────
     ONE size per visual role, sitewide. Values are the site's own, kept as
     anchors so nothing jumps; where two pages had drifted apart for the same
     role the Strategy/Design/Marketing template's value won, because those
     three are the pattern every other page copies. Section stylesheets
     CONSUME these — they must not re-declare a raw clamp for a role that
     already has a token. Mobile variants exist only where the desktop clamp's
     floor is the wrong answer at 390px, not as a second scale. */
  --fs-display:clamp(3rem,6vw,5.6rem);          /* h1 / .display */
  --fs-hero:clamp(2rem,4.6vw,4.4rem);           /* the home hero title */
  --fs-hero-m:clamp(1.6rem,7.4vw,2.4rem);
  --fs-xl:clamp(2.1rem,4.6vw,3.8rem);           /* h2 / .t-xl / footer talk / mission head */
  --fs-statement:clamp(1.7rem,4.3vw,3.5rem);    /* the one-line statements: range, constellation, mission, offers */
  --fs-statement-m:clamp(1.7rem,7.6vw,2.4rem);
  --fs-lede:clamp(1.7rem,3.2vw,2.9rem);         /* page lede, big half */
  --fs-lede-m:clamp(1.6rem,7vw,2.1rem);
  --fs-title:clamp(1.5rem,3vw,2.5rem);          /* service name, next-case name */
  --fs-head:clamp(1.45rem,2.4vw,2.05rem);       /* offer/door titles, form questions */
  --fs-lg:clamp(1.3rem,2.2vw,1.95rem);          /* h3 / .t-lg */
  --fs-sub:clamp(1.15rem,1.9vw,1.6rem);         /* step + tile titles, the total line */
  --fs-md:clamp(1.1rem,1.55vw,1.42rem);         /* .t-md — lede-scale body */
  --fs-md-m:clamp(1.05rem,4.4vw,1.25rem);
  --fs-body-lg:clamp(1rem,1.35vw,1.2rem);       /* case intro, service desc, journey lede */
  --fs-body:clamp(0.95rem,1.05vw,1.0625rem);    /* .t-body — the paragraph */
  --fs-btn:15px;                                /* every pill on the site */
  --fs-sm:15px;                                 /* small body: inputs, totals, sent-copy */
  --fs-meta:14.5px;                             /* data values */
  --fs-xs:13.5px;                               /* footer links, notes, option descriptions */
  --fs-micro:11.5px;                            /* mono status + error lines */
  --fs-mono:clamp(0.8rem,1.05vw,0.98rem);       /* the mono caption: .subhead, constellation primary labels */
  --fs-label-m:10.5px;                          /* mono label, menu scale */
  --fs-label:clamp(8.5px,0.62vw,10.5px);        /* .overline — the house eyebrow */
  --fs-label-s:9.5px;                           /* mono micro-labels, badges, segmented buttons */

  /* Line-height + tracking pairs per role. Display type tightens as it grows;
     body loosens. Consumed by the role classes below. */
  --lh-display:1.04; --ls-display:-0.04em;
  --lh-lede:1.14;    --ls-lede:-0.015em;   /* 12 Sep, his value */
  /* 12 Sep, Haseeb: "-0.015em for hero-title too (basically all large inter)".
     One token for the display scale, so the hero, the page titles, the
     statements and the big ledes all track the same and a new one inherits it.
     Numerals keep their own tighter values — a giant tabular number set at
     display tracking reads as gappy rather than airy. */
  --ls-display:-0.015em;
  --lh-head:1.15;    --ls-head:-0.03em;
  --lh-body:1.65;    --ls-body:normal;
  --lh-label:1.4;    --ls-label:0.16em;
}
/* lvh override for --section-y, in @supports rather than a second
   declaration on the line above — see constellation.css's own note on
   --cn-pearl-gap for why a custom property can't use the plain
   declare-twice trick every direct property in this pass uses: it doesn't
   validate its value at assignment, so an unconditional second declaration
   would silently overwrite the working vh value on a browser that then
   fails to resolve lvh wherever --section-y is actually consumed, dropping
   the padding to nothing instead of falling back to it.
   lvh, not svh: "prevent the screen resizing on mobile — open for whichever
   [viewport height] is largest", his words. `svh` was the earlier choice
   (small viewport height, pinned to the toolbar-EXPANDED size) precisely so
   nothing sat behind the toolbar — but that undersizes once the toolbar
   retracts on scroll, leaving a gap of bare background where the section
   should still be. `lvh` is the opposite fixed value — the toolbar-HIDDEN
   size — so the section always fills the full screen once scrolling starts;
   the accepted trade is that on first paint, with the toolbar still expanded,
   a few px at the very bottom may sit fractionally under it rather than
   short of it. */
@supports (height: 1lvh) {
  :root{ --section-y:clamp(96px,14lvh,180px); --vh:1lvh; }
}

/* ── THE WORDMARK FACE ──────────────────────────────────────────────────────
   Bizmo, Black weight only — "the logo isnt using the Bizmo font. logo in
   intro too", his words. It is a real, licensed family already installed on
   his machine (~/Library/Fonts/Bizmo-*.otf, the full Thin→Black range plus
   obliques); this project only ships the ONE cut the wordmark actually uses,
   assets/fonts/Bizmo-Black.otf — not all eighteen files, which nothing here
   needs. font-weight:900 on the @font-face itself is what lets a plain
   `font-weight:900` in the rules below resolve to this specific file rather
   than a synthesized fake-bold of some other weight, and leaves room to add
   more real cuts later under the same family name without renaming anything.
   font-display:swap: the header and the intro's drawn "RZV" are both above
   the fold and about as prominent as this site gets, so a brief fallback-font
   flash while this loads is far better than either invisible text or a
   layout that waits on it. Used by .brand-text (base.css) and .rz-word
   (intro.css) — both, per the ask; keep them in step if this ever changes. */
@font-face{
  font-family:'Bizmo';
  src:url('../fonts/Bizmo-Black.otf') format('opentype');
  font-weight:900;font-style:normal;font-display:swap;
}


/* ── PHASE-LIGHT ──────────────────────────────────────────────────────────
   The white takeover (#constellation). Implemented as a token swap on
   <body> rather than a pile of per-element overrides: every rule below reads
   --text/--border/--surface, so the whole site is correct in both phases with
   nothing extra to maintain. intro.js / constellation.js flip it through
   RZV.phase('light') / RZV.phase('dark').
   `html:has()` carries the same background out to the overscroll gutter, which
   <body> alone cannot reach. ── */
body.phase-light{
  color-scheme:light;

  /* Every value below is the dashboard's light theme verbatim (focus.html
     html[data-theme="light"]) — EXCEPT --bg. The dashboard's light page is
     #FAFAF8, a warm paper; this one is a deliberate #FFFFFF because the
     takeover is a white-out, not a theme. Keep them in sync when the
     dashboard's light values move; keep --bg pure. */
  --bg:#FFFFFF; --bg-2:#F3F3F6;
  --surface:rgba(0,0,0,0.022); --surface-2:rgba(0,0,0,0.04); --surface-hi:rgba(0,0,0,0.06);
  --border:rgba(0,0,0,0.08); --border-hi:rgba(0,0,0,0.16);
  --text:#17171A; --muted:rgba(20,20,25,0.52); --dim:rgba(20,20,25,0.32); --faint:rgba(20,20,25,0.12);
  --card:rgba(255,255,255,0.62); --card-hi:rgba(255,255,255,0.8); --tile-sheen:rgba(0,0,0,0.045);
  --pos:#16A34A; --neg:#DC2626;
  /* Indigo, not violet: on white the violet wash greys out, and indigo is what
     the dashboard's light aura reads as. */
  --aura:rgba(79,70,229,0.05);
}
html:has(body.phase-light){background:#FFFFFF}

/* ── Reset ──────────────────────────────────────────────────────────────── */
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0;-webkit-tap-highlight-color:transparent}   /* no grey tap flash on iOS (6 Sep) */
/* overflow-x:hidden on HTML, and on html ONLY — found chasing the
   header restructure ("pearl top left, logo middle, hamburger right"): on a
   narrow viewport the aurora/mesh blobs (.ab, .mb, .au — deliberately huge and
   negative-positioned so their blur bleeds off both edges) escape body's own
   clip, because position:fixed/absolute descendants size against the INITIAL
   CONTAINING BLOCK, not body. Mobile browsers respond by silently WIDENING the
   whole layout viewport to fit them — window.innerWidth becomes larger than
   the true device width — and every 100%/inset:0/position:fixed element on
   the page (the header included) then sizes against that inflated viewport
   too, so .burger, the header's own right-edge control, ends up pushed off the
   true visible edge. html needs the same overflow-x:hidden body already had:
   that is what actually stops the viewport from being renegotiated in the
   first place, rather than just hiding a scrollbar after the fact.

   ⚠️ AND BODY'S IS NOW GONE, WHICH IS THE POINT OF THE NOTE ABOVE.
   overflow-x:hidden on body BREAKS position:sticky anywhere inside it. The
   spec is explicit: when one axis is `hidden` and the other is `visible`, the
   visible one computes to `auto` — so body silently became a scroll container,
   and #constellation's sticky stage began sticking to BODY's scrollport
   instead of the document's. Body's scrollport never scrolls (the document
   scrolls on html), so the stage had nothing to stick against and simply rode
   the page up: measured on the live site at 1440x900, the stage's top ran to
   -162, -972, -4860px while the core pearl — position:fixed, and so still
   correct — sat immovably at the viewport centre. Haseeb saw it as "the ven
   diagram doesnt stay in place centered to the central node", which is exactly
   what it was.

   Removing it costs nothing: the sentence above is the reason. html's rule is
   what actually prevents the layout viewport from being renegotiated, and it
   clips the same bleeding blobs at the same edge. Body's was the older, weaker
   half of the pair and was made redundant the moment html got one — this is
   the deletion that should have happened in the same commit.

   Anything added here in future must not be `overflow-x:hidden` on a sticky
   ancestor. If a descendant ever really needs its own horizontal clip, give it
   to that element, not to body. */
html{background:var(--bg);-webkit-text-size-adjust:100%;overflow-x:hidden}
html:focus-within{scroll-behavior:smooth}
/* ── LENIS (12 Sep 2026) ──
   The four rules its own stylesheet ships, inlined rather than pulled as a
   seventh <link>. .lenis-smooth turning scroll-behavior back to `auto` is the
   important one: the rule above and a lerped chase would otherwise both be
   animating the same scrollTop, and the browser's smooth-scroll would fight
   Lenis to a crawl on every anchor jump. See assets/js/smooth.js — none of
   these classes exist on a touch pointer, where Lenis never boots. */
html.lenis,html.lenis body{height:auto}
/* On html.lenis, not html.lenis.lenis-smooth as Lenis's own sheet has it:
   the -smooth class was not appearing on this build, and the rule matters
   the moment an instance exists, not the moment it labels itself. */
html.lenis{scroll-behavior:auto!important}
html.lenis.lenis-smooth [data-lenis-prevent]{overscroll-behavior:contain}
html.lenis.lenis-stopped{overflow:hidden}
body{
  /* 12 Sep, Haseeb: "change my default font to Geist". Served from Google
     (variable, 300–600 — the range this site actually sets); Silka Mono is
     self-hosted and unaffected. system-ui carries the first frames. */
  font-family:'Geist',system-ui,-apple-system,'Segoe UI',sans-serif;
  background:var(--bg);color:var(--text);
  line-height:1.55;font-weight:400;
  -webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;
  font-feature-settings:"ss01","cv11";
  min-height:100vh;min-height:100lvh;
  transition:background-color .9s var(--ease),color .9s var(--ease);
}
/* min-height declared twice above — vh, then lvh — "fix the whole iOS
   toolbar issue with vh / svh / dvh", and later, "prevent the screen resizing
   on mobile — open for whichever [viewport height] is largest". Neither
   recomputes as Mobile Safari's toolbar animates on scroll, unlike plain
   `vh`; the plain `vh` stays first as the fallback for anything predating
   Safari 15.4's svh/lvh/dvh support, and the second declaration silently wins
   wherever it's understood. `lvh` (not `svh`) on purpose: `svh` is pinned to
   the SMALLEST size — toolbar fully expanded — so it never sits behind the
   toolbar, but undersizes the instant the toolbar retracts on scroll,
   leaving a bare gap at the bottom; `lvh` is pinned to the LARGEST size —
   toolbar hidden — so a scrolled page never shows that gap, at the cost of a
   few px sitting fractionally under the toolbar on first paint instead.
   Applied wherever a real viewport-height dependency exists in this
   codebase — see this same pattern in constellation.css (THE RUNWAY) and
   intro.css (§4). */
/* PAGE AURA WASH — focus.html:63, verbatim. Fixed, so it stays anchored to the
   top of the VIEWPORT rather than scrolling away with the document: the whole
   site keeps one faint source of light overhead. z-index 0 and it is the first
   box in body, so every section (position:relative, z auto) paints over it.
   body::before is unclaimed on this site — nothing else may take it. */
body::before{content:'';position:fixed;inset:0;
  background:radial-gradient(ellipse 900px 500px at 50% -15%,var(--aura),transparent 62%);
  pointer-events:none;z-index:0}
/* Custom properties do not interpolate, so a phase swap would otherwise be a
   hard cut on every child. This softens the elements that have no opinion of
   their own about it.

   IT ONLY REACHES ELEMENTS WITH NO `transition` SHORTHAND OF THEIR OWN. These
   are longhands; any later shorthand at equal-or-higher specificity resets all
   of them wholesale. That is not a bug to route around — an element that
   declares its own transition has already said how it fades, and it keeps
   saying it. .nav-link, .btn, .ft-link, .wt-name (work.css:141) and .pp-name
   (work.css:178) each carry `color` in their own shorthand, so they cross-fade
   on their own clock; .site-header carries only opacity/transform and does
   hard-cut its background, which is invisible because html.hdr-veil has it
   hidden for the whole light phase. All six were listed here and are not, now.

   So: add a selector here only if it has NO transition shorthand anywhere. */
body,.brand-text,.overline,p,h1,h2,h3,h4,li,a,.wt-meta{
  transition-property:color,border-color,background-color;
  transition-duration:.7s;transition-timing-function:var(--ease);
}
/* ── THE INTRO'S SCROLL LOCK — ON THE ROOT, NEVER ON BODY ─────────────────
   The film owns the page until it has composed, so the reader cannot scroll
   out of it. This USED to be `body.loading{overflow:hidden}`, and that was
   wrong twice over — for the same reason, and it is the reason written out at
   length above html's own rule:

   1. IT BROKE STICKY. overflow:hidden on one axis computes the other to
      `auto`, so body became a scroll container and #constellation's sticky
      stage began sticking to BODY's scrollport — which never scrolls, because
      the document scrolls on the viewport. A deep reload into #constellation
      with the film replaying showed the venn riding the page up, unpinned,
      until the lock came off. Exactly the bug measured on the live site when
      body still carried overflow-x:hidden; same mechanism, same fix.

   2. IT DID NOT ACTUALLY LOCK ANYTHING. A body overflow is propagated to the
      viewport ONLY when the root element's own overflow is `visible`
      (CSS Overflow 3 §3.3). html carries overflow-x:hidden here — deliberately,
      see above — so the root is not visible in both axes, body's value is NOT
      propagated, and the page went on scrolling behind the film. The sticky
      breakage is itself the proof: a propagated value leaves the source
      element `visible`, so body could only have become a scroll container by
      NOT being propagated.

   ON THE ROOT IT IS THE OPPOSITE OF BOTH. The root's overflow IS applied to
   the viewport, and the element it was taken from is then treated as
   `visible` — so html never becomes a scroll container either, the viewport
   stays the scrollport every position:sticky descendant resolves against, and
   the page genuinely cannot be scrolled. Sticky keeps working while the lock
   is on: nothing about the scroll container has changed, only whether it is
   allowed to move. (That is a reading of the spec, not something anyone has
   watched happen — no browser was available to this pass.)

   `html:has(body.loading)` rather than a class on html, and that is the
   robust half: three separate places take this lock off — intro.js's
   endLoader(), nav.js's endLoader(), and index.html's inline 2.5s safety net
   — and glass.js probes for it by reading body's classList. One selector
   keyed off the class they all already write means there is no second flag to
   desync, and no path on which a crashed intro can leave the page locked.
   `:has()` is already load-bearing in this file (html:has(body.phase-light)
   above); an engine too old for it simply gets no lock, which is a film that
   can be scrolled — the behaviour that was shipping anyway.

   AND THE GUTTER IS PAID IN CSS, not in JS like the modal locks. Now that the
   lock genuinely retires the classic scrollbar (it never did before — see 2),
   desktop Chrome/Firefox would widen the layout viewport by ~15px for the
   film's length and hand it back on the frame the hero composes, i.e. a 15px
   jump under a hero that has just finished arriving. `scrollbar-gutter:stable`
   reserves exactly that width for exactly as long as the lock is on and
   reserves nothing when it is off, so there is nothing to measure and nothing
   to write back; it is on the root, where it propagates to the viewport with
   the overflow it accompanies. This is the same trick services.css rejected —
   rightly — as a permanent site-wide reservation; scoped to the lock it costs
   nothing when the lock is not held. On overlay-scrollbar platforms (macOS by
   default, every phone) the reserved width is zero and this is inert.
   ⚠️ Reasoned from the spec, not watched in a browser. ── */
html:has(body.loading){overflow:hidden;scrollbar-gutter:stable}
img,svg,video{display:block;max-width:100%}
img{height:auto}
a{color:inherit;text-decoration:none}
button,input,select,textarea{font:inherit;color:inherit}
button{background:none;border:none;cursor:pointer}
ul,ol{list-style:none}
:focus-visible{outline:2px solid var(--a2);outline-offset:3px;border-radius:4px}
/* Touch: Safari flags a tapped control as :focus-visible and draws the ring
   after the tap. Keyboard users on a pointer device keep it. (6 Sep) */
/* Touch: no focus rings at all. The bare :focus-visible reset lost to every
   component rule with higher specificity (.mnav-link:focus-visible drew the
   ring round "Work" when the menu opened and focused it). !important is the
   honest tool here: one media query that outranks all of them. (7 Sep) */
@media (pointer:coarse){
  *:focus-visible, *:focus{outline:none !important}
  .rzv-switch input:focus-visible ~ .rzv-switch-track{outline:none !important}
  .work-tile:focus-visible .wt-media{outline:none !important;box-shadow:none !important}
  .ef-input:focus-visible{outline:none !important}
}
::selection{background:rgba(129,140,248,0.28);color:var(--text)}
/* A 0.28-alpha indigo over white is nearly invisible, and --text has flipped
   to near-black underneath it — both halves have to move together. */
body.phase-light ::selection{background:rgba(79,70,229,0.22);color:#17171A}

/* ── Layout ─────────────────────────────────────────────────────────────── */
.wrap{width:100%;max-width:var(--wrap);margin:0 auto;padding:0 var(--gutter);position:relative;z-index:1}
.section{position:relative;padding:var(--section-y) 0}
.section-head{display:flex;flex-direction:column;gap:18px;margin-bottom:clamp(44px,6vw,86px)}

/* ── Typography ──────────────────────────────────────────────────────────
   Display = Inter 300 at −0.04em. Weight is the whole voice here: 300 at
   5.6rem reads as drawn, 400 reads as a system font blown up. ── */
.display,h1,h2{font-weight:300;letter-spacing:var(--ls-display);line-height:1.04}
h1{font-size:var(--fs-display)}
h2,.t-xl{font-size:var(--fs-xl)}
h3,.t-lg{font-size:var(--fs-lg);font-weight:400;letter-spacing:var(--ls-head);line-height:var(--lh-head)}
.t-md{font-size:var(--fs-md);font-weight:300;letter-spacing:-0.02em;line-height:1.4}
.t-body{font-size:var(--fs-body);line-height:var(--lh-body);color:var(--muted)}
/* Services pages read sparse, so body copy steps up one size there (Haseeb,
   16 Sep 2026): everything keyed to --fs-body on those pages (.t-body process
   paragraphs, .st-lede-small, .st-step p, .page-lede--note) rides the token,
   so one override lifts them all. --fs-body-lg is the existing next step. */
body[data-page="services"]{--fs-body:var(--fs-body-lg)}
.measure{max-width:56ch}

/* HOUSE RULE (BRIEF §Design tokens): every overline / eyebrow label on this
   site is Space Mono, uppercase, 8.5–10.5px, .10–.18em tracking. Space Mono
   ships 400/700 only — 700 is what a `600` request resolves to anyway, so it
   is declared honestly. Recipe: settings.js `.sd-overline`. */
.overline{
  font-family:var(--mono);font-size:var(--fs-label);font-weight:700;
  letter-spacing:var(--ls-label);text-transform:uppercase;color:var(--muted);
  display:block;line-height:var(--lh-label);
}
.overline--dim{color:var(--dim)}

/* ── PEARL ───────────────────────────────────────────────────────────────
   Verbatim material from portal.css `.ptl-pearl` (the 3-layer iridescent
   stack) — a white specular highlight, a 240%-wide aurora sweep that the
   iriDrift keyframe walks left→right, and the base pearl gradient underneath.
   Parameterised by --pearl-size so ONE class covers the 9px header dot, the
   25px hero pearl and the full-screen moon of the loader: every offset is a
   ratio of the size, floored with max() so the inner highlight survives at
   9px. --pearl-glow is the outer bloom's alpha — intro.js pumps it to sell
   the dying-star climax.
   `transform` is absent from the transition list on purpose (trap 2). ── */
.pearl{
  --pearl-size:34px;
  --pearl-glow:0.45;
  position:relative;flex:none;
  width:var(--pearl-size);height:var(--pearl-size);border-radius:50%;
  border:1px solid rgba(255,255,255,0.65);
  background:
    radial-gradient(circle at 32% 26%, rgba(255,255,255,0.95), rgba(255,255,255,0) 58%),
    linear-gradient(120deg, rgba(129,140,248,0.34), rgba(244,114,182,0.26) 32%, rgba(34,211,238,0.30) 62%, rgba(167,139,250,0.32) 95%),
    linear-gradient(160deg,#ffffff,#e9ecf6);
  background-size:100% 100%, 240% 240%, 100% 100%;
  box-shadow:
    inset 0 max(1px, calc(var(--pearl-size) * 0.03)) max(1px, calc(var(--pearl-size) * 0.03)) rgba(255,255,255,0.9),
    inset 0 calc(var(--pearl-size) * -0.059) calc(var(--pearl-size) * 0.176) rgba(167,139,250,0.28),
    0 calc(var(--pearl-size) * 0.117) calc(var(--pearl-size) * 0.412) calc(var(--pearl-size) * -0.117) rgba(129,140,248,var(--pearl-glow));
  animation:iriDrift 7s ease-in-out infinite;
  transition:filter .3s var(--ease),box-shadow .4s var(--ease);
}
@keyframes iriDrift{0%,100%{background-position:0 0,0% 50%,0 0}50%{background-position:0 0,100% 50%,0 0}}
/* On white the indigo bloom disappears and the pearl loses its edge — swap
   the outer shadow for a soft graphite one. */
body.phase-light .pearl{
  border-color:rgba(10,8,10,0.14);
  box-shadow:
    inset 0 max(1px, calc(var(--pearl-size) * 0.03)) max(1px, calc(var(--pearl-size) * 0.03)) rgba(255,255,255,0.95),
    inset 0 calc(var(--pearl-size) * -0.059) calc(var(--pearl-size) * 0.176) rgba(129,140,248,0.34),
    0 calc(var(--pearl-size) * 0.117) calc(var(--pearl-size) * 0.412) calc(var(--pearl-size) * -0.117) rgba(10,8,10,calc(var(--pearl-glow) * 0.4));
}

/* ── GLASS ───────────────────────────────────────────────────────────────
   portal.css `.ptl-glass` — the one surface shared by chips, tiles and the
   composer, so every floating thing on the site is literally the same
   material.

   ⚠️ NOT DEAD — DO NOT DELETE. Nothing currently carries `class="glass"`, so a
   usage census will call this unused. It is the CANONICAL recipe: work.css
   re-implements it twice (`.pf-input`, `.pf-chip`) and the copies have already
   drifted apart (box-shadow -16px here and in .pf-input, -18px in .pf-chip).
   Those two should compose onto this class instead; the consolidation is
   dispatched separately. Delete this and the drift becomes permanent. ── */
.glass{
  border-radius:var(--r-tile);
  border:1px solid rgba(255,255,255,0.12);
  background:linear-gradient(160deg,rgba(255,255,255,0.09),rgba(255,255,255,0.02) 50%);
  backdrop-filter:blur(20px) saturate(1.45);-webkit-backdrop-filter:blur(20px) saturate(1.45);
  box-shadow:inset 0 1px 0 rgba(255,255,255,0.14), 0 10px 28px -16px rgba(0,0,0,0.4);
  /* The spring on transform is part of the recipe, not a flourish: .ptl-glass
     is a PRESSABLE surface in the dashboard and the overshoot is what makes it
     feel like one. It was missing here, so glass read as inert. Unlike .tilt,
     nothing GSAP-driven owns transform on .glass — trap 2 does not apply. */
  transition:border-color .35s var(--ease),background .35s var(--ease),transform .25s var(--ease-spring);
}
.glass:hover,.glass:focus-visible{
  border-color:rgba(255,255,255,0.22);
  background:linear-gradient(160deg,rgba(255,255,255,0.13),rgba(255,255,255,0.04) 50%);
}
body.phase-light .glass{
  border-color:rgba(10,8,10,0.10);
  background:linear-gradient(160deg,rgba(255,255,255,0.9),rgba(255,255,255,0.45) 50%);
  box-shadow:inset 0 1px 0 rgba(255,255,255,0.9), 0 10px 28px -18px rgba(10,8,10,0.22);
}
body.phase-light .glass:hover{border-color:rgba(10,8,10,0.2)}

/* ── BUTTONS / PILLS ────────────────────────────────────────────────────
   Glass at rest. The aurora face (portal.css `.ptl-cbtn.primary`) lives on a
   ::before and fades in — gradients cannot interpolate, an opacity ramp on a
   second layer can. ── */
.btn{
  position:relative;isolation:isolate;overflow:hidden;
  display:inline-flex;align-items:center;justify-content:center;gap:10px;
  padding:15px 28px;border-radius:var(--r-pill);
  font-size:var(--fs-btn);font-weight:500;letter-spacing:-0.01em;color:var(--text);
  border:1px solid var(--border-hi);
  background:linear-gradient(160deg,rgba(255,255,255,0.09),rgba(255,255,255,0.02) 50%);
  backdrop-filter:blur(20px) saturate(1.45);-webkit-backdrop-filter:blur(20px) saturate(1.45);
  cursor:pointer;
  transition:transform .28s var(--ease-spring),border-color .35s var(--ease),color .35s var(--ease);
}
.btn>*{position:relative;z-index:1}
/* Rest opacity is 0.35, not 0. The dashboard's .ptl-cbtn wears its aurora
   permanently — the colour is what the button IS, and hovering only brings it
   to full. Starting at 0 made every button a grey capsule until touched, which
   is the one thing the house material never does. */
.btn::before{
  content:'';position:absolute;inset:0;z-index:0;opacity:0.35;pointer-events:none;
  background:
    linear-gradient(160deg, rgba(255,255,255,0.45), rgba(255,255,255,0) 44%),
    linear-gradient(115deg, rgba(244,114,182,0.92), rgba(167,139,250,0.86) 48%, rgba(34,211,238,0.94));
  background-size:100% 100%, 200% 200%;
  transition:opacity .4s var(--ease);
}
.btn:hover{border-color:rgba(255,255,255,0.42)}   /* 12 Sep: no lift — the label rolls instead */
.btn:hover::before{opacity:1}
.btn:active{transform:translateY(0) scale(0.985)}
body.phase-light .btn{border-color:rgba(10,8,10,0.14);
  background:linear-gradient(160deg,rgba(255,255,255,0.92),rgba(255,255,255,0.5) 50%)}

/* The Start-project CTA: same recipe, bigger, and it goes fully aurora with
   near-black type on hover so it reads as the one committed action on the
   page. `--btn-drift` is idle motion on the aurora layer, frozen when motion
   is off. */
.btn-primary{padding:19px 38px;font-size:var(--fs-btn);font-weight:600}
.btn-primary::before{animation:btnDrift 9s ease-in-out infinite}
.btn-primary:hover{color:#0b0b14;border-color:rgba(255,255,255,0.42)}
body.phase-light .btn-primary:hover{color:#0b0b14}
@keyframes btnDrift{0%,100%{background-position:0 0,0% 50%}50%{background-position:0 0,100% 50%}}

.btn-lg{padding:24px 46px;font-size:clamp(15px,1.5vw,18px);border-radius:var(--r-pill)}

/* THE SECONDARY — portal.css `.ptl-cbtn` (non-primary). Quarter-strength
   aurora, painted into the surface rather than layered on a ::before, so it is
   permanent: this button is coloured at rest and stays that strength on hover.
   That is the whole distinction it carries. Letting the shared ::before run
   would stack a second aurora on top and ramp it to primary strength on hover,
   collapsing the hierarchy the pair exists to express — so it is suppressed in
   both states, and hover is the lift plus a brighter rim instead. */
.btn-ghost{
  color:var(--text);
  border-color:rgba(255,255,255,0.16);
  background:
    linear-gradient(160deg, rgba(255,255,255,0.22), rgba(255,255,255,0) 42%),
    linear-gradient(135deg, rgba(244,114,182,0.15), rgba(129,140,248,0.10) 45%, rgba(34,211,238,0.15));
}
.btn-ghost::before,.btn-ghost:hover::before{opacity:0}
.btn-ghost:hover{color:var(--text);border-color:rgba(255,255,255,0.30)}
body.phase-light .btn-ghost{border-color:rgba(0,0,0,0.12)}
body.phase-light .btn-ghost:hover{border-color:rgba(0,0,0,0.24)}

/* A primary and a secondary standing side by side. Wraps to a column before
   the pair can ever overflow the gutter. */
.contact-ctas{display:flex;flex-wrap:wrap;align-items:center;gap:14px}

/* ── SWITCH (settings.js `.sd-switch`) ──────────────────────────────────
   Wired on this site as MOTION ON/OFF, persisted at localStorage
   'rzv-motion' — see nav.js. ── */
.rzv-switch{position:relative;width:38px;height:22px;flex-shrink:0;display:inline-block}
.rzv-switch input{position:absolute;inset:0;opacity:0;margin:0;cursor:pointer;z-index:1}
.rzv-switch-track{position:absolute;inset:0;background:var(--surface-hi);border:1px solid var(--border);
  border-radius:var(--r-pill);transition:background .25s var(--ease),border-color .25s var(--ease)}
.rzv-switch-thumb{position:absolute;top:2px;left:2px;width:16px;height:16px;border-radius:50%;
  background:var(--muted);transition:transform .25s var(--ease-spring),background .25s var(--ease)}
.rzv-switch input:checked ~ .rzv-switch-track{background:var(--text);border-color:var(--text)}
.rzv-switch input:checked ~ .rzv-switch-thumb{transform:translateX(16px);background:var(--bg)}
.rzv-switch input:focus-visible ~ .rzv-switch-track{outline:2px solid var(--a2);outline-offset:3px}

/* ── MESH GRADIENTS (client-page.css:12–39) ─────────────────────────────
   Three brand-coloured blooms drifting on 26/34/41s alternate loops, tinted
   per host from --m1/--m2/--m3. Default is `position:absolute` here (work
   tiles, case heroes); `.mesh--page` restores the fixed, masked page-backdrop
   form the dashboard uses.
   mix-blend-mode:screen is what makes them read as light rather than paint —
   it is dropped, not faked, on mobile (trap 1, bottom of file). ── */
.mesh{position:absolute;inset:0;z-index:0;pointer-events:none;overflow:hidden;
  opacity:0;transition:opacity 1.2s var(--ease)}
.mesh.on{opacity:1}
.mesh--page{position:fixed;top:0;left:0;right:0;bottom:auto;height:62vh;height:62lvh;
  -webkit-mask-image:linear-gradient(to bottom,#000 0%,rgba(0,0,0,0.62) 38%,transparent 88%);
  mask-image:linear-gradient(to bottom,#000 0%,rgba(0,0,0,0.62) 38%,transparent 88%)}
body.phase-light .mesh.on{opacity:0.28}
.mb{position:absolute;border-radius:50%;mix-blend-mode:screen;filter:blur(var(--mesh-blur,42px));will-change:transform}
.mb.m1{background:radial-gradient(circle, color-mix(in srgb, var(--m1,transparent) 62%, transparent), transparent 66%);
  width:78%;height:150%;left:-22%;top:-58%;animation:meshA 26s ease-in-out infinite alternate}
.mb.m2{background:radial-gradient(circle, color-mix(in srgb, var(--m2,transparent) 48%, transparent), transparent 66%);
  width:66%;height:135%;left:28%;top:-66%;animation:meshB 34s ease-in-out infinite alternate}
.mb.m3{background:radial-gradient(circle, color-mix(in srgb, var(--m3,transparent) 40%, transparent), transparent 68%);
  width:70%;height:140%;left:60%;top:-60%;animation:meshC 41s ease-in-out infinite alternate}
@keyframes meshA{0%{transform:none}50%{transform:translate(9%,5%) scale(1.14)}100%{transform:translate(-6%,-3%) scale(0.94)}}
@keyframes meshB{0%{transform:translate(0,0) scale(1.06)}50%{transform:translate(-11%,6%) scale(0.9)}100%{transform:translate(7%,-4%) scale(1.18)}}
@keyframes meshC{0%{transform:translate(0,0) scale(0.96)}50%{transform:translate(8%,-6%) scale(1.2)}100%{transform:translate(-9%,4%) scale(1.02)}}

/* ── AURORA BLOBS (portal.css:90–119) ───────────────────────────────────
   Six screen-blended radials with a five-stop falloff. The five stops are the
   point: a single `transparent 64%` leaves a visible ring where the
   interpolation stops, and these are large enough that the ring would show.

   ARCHITECTURAL RULE — the blob geometry below is ASPECT-RATIO-LOCKED. Every
   width/height/left/bottom is a percentage of this box, tuned as one
   composition against the dashboard's panel proportions. NEVER adapt a blob's
   size or position to fit a new home: size and anchor the HOST instead (see
   .au), and the composition arrives intact. A blob nudged to "look right" in
   one host is a blob that is wrong in every other. ── */
.aurora{position:absolute;inset:-22%;pointer-events:none;z-index:0;
  transition:opacity .55s var(--ease);
  /* Load-bearing, and not only for softness: a filter makes this element a
     stacking context, which isolates the six mix-blend-mode:screen children
     into their own group. They then composite against each OTHER — which is
     the light-on-light bloom the dashboard shows — instead of screening
     individually against whatever the page happens to have behind them. */
  filter:blur(var(--au-blur,12px)) saturate(var(--au-sat,1.6)) brightness(1.05)}
.aurora .ab{position:absolute;mix-blend-mode:screen;border-radius:50%;will-change:transform,opacity}
.aurora .ab.a1{width:124%;height:110%;left:-41%;bottom:-41%;
  background:radial-gradient(circle closest-side,rgba(79,70,229,1),rgba(79,70,229,0.82) 22%,rgba(79,70,229,0.5) 42%,rgba(79,70,229,0.22) 60%,rgba(79,70,229,0.06) 78%,rgba(79,70,229,0) 92%)}
.aurora .ab.a2{width:104%;height:92%;left:-3%;bottom:3%;
  background:radial-gradient(circle closest-side,rgba(129,140,248,1),rgba(129,140,248,0.8) 22%,rgba(129,140,248,0.48) 42%,rgba(129,140,248,0.2) 60%,rgba(129,140,248,0.05) 78%,rgba(129,140,248,0) 92%)}
/* 12 Sep, Haseeb: "I only see pink and purple. Move the green ish towards top
   right." The one cyan of the six sat bottom-right with 31% of itself below the
   box, so most of what was green-blue was never on screen — and what was left
   sat under the pink. Top right now — and sized so its CENTRE lands on screen
   at about 78% across and 18% down. That is the whole trick with these: a
   radial gradient is only bright at its middle, so a blob whose centre is off
   the edge contributes nothing but its faintest falloff, which is how the one
   cool note in the set went missing in the first place. */
.aurora .ab.a3{width:92%;height:80%;left:23%;top:-12%;
  background:radial-gradient(circle closest-side,rgba(34,211,238,1),rgba(34,211,238,0.78) 22%,rgba(34,211,238,0.46) 42%,rgba(34,211,238,0.19) 60%,rgba(34,211,238,0.05) 78%,rgba(34,211,238,0) 92%)}
.aurora .ab.a4{width:108%;height:96%;left:25%;top:-25%;
  background:radial-gradient(circle closest-side,rgba(167,139,250,1),rgba(167,139,250,0.8) 22%,rgba(167,139,250,0.48) 42%,rgba(167,139,250,0.2) 60%,rgba(167,139,250,0.05) 78%,rgba(167,139,250,0) 92%)}
.aurora .ab.a5{width:92%;height:84%;left:-26%;bottom:14%;
  background:radial-gradient(circle closest-side,rgba(244,114,182,1),rgba(244,114,182,0.76) 22%,rgba(244,114,182,0.44) 42%,rgba(244,114,182,0.18) 60%,rgba(244,114,182,0.04) 78%,rgba(244,114,182,0) 92%)}
.aurora .ab.a6{width:96%;height:86%;left:8%;bottom:-44%;
  background:radial-gradient(circle closest-side,rgba(99,102,241,1),rgba(99,102,241,0.8) 22%,rgba(99,102,241,0.48) 42%,rgba(99,102,241,0.2) 60%,rgba(99,102,241,0.05) 78%,rgba(99,102,241,0) 92%)}

/* ── .au — THE AURORA HOST ──────────────────────────────────────────────
   Mirrors .mesh's contract exactly: an empty positioned box that RZV.aurora()
   fills with the six blobs, invisible until something adds .on. Strength is
   --au-o so each host picks its own without touching the blobs.

   LOAD-BEARING: a host MUST pin its own aspect-ratio (and its own size and
   anchor). The blob composition is ratio-locked — see the rule above — so a
   host left to be whatever shape its content makes it will stretch the whole
   arrangement into a smear. Every host declares `aspect-ratio` and a width;
   #contact > .au below is the worked example. Never fix a bad-looking aurora
   by editing .ab; fix the host's box. ── */
.au{position:absolute;z-index:0;pointer-events:none;overflow:hidden;
  opacity:0;transition:opacity 1.2s var(--ease)}
.au.on{opacity:var(--au-o,0.42)}

/* ── FILM GRAIN (portal.css:290–296) ────────────────────────────────────
   feTurbulence as a data URI, `screen` at 0.045. Screen and not overlay:
   overlay(0) = 0, i.e. invisible on exactly the near-black surface that needs
   the texture. Screen lifts instead. ── */
.grain{
  position:fixed;inset:0;z-index:60;pointer-events:none;
  mix-blend-mode:screen;opacity:0.045;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size:160px 160px;background-repeat:repeat;
}
/* Screen on white is a no-op — flip to multiply so the grain still bites. */
body.phase-light .grain{mix-blend-mode:multiply;opacity:0.05}

/* ── TILT TILE (focus.html:77–86 CSS + perf-sections.js:607–627 JS) ─────
   The sheen tracks the cursor through --mx/--my; the ::after is a soft outer
   glow parked behind the translucent card. `transform` is NOT in the
   transition list — RZV.attachTilt() writes rotationX/rotationY/y/scale every
   frame (trap 2). ── */
.tilt{
  position:relative;
  background:var(--card);border:1px solid var(--border);border-radius:var(--r-tile);
  overflow:hidden;backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px);
  transition:border-color .3s ease,background .3s ease;
}
.tilt::before{content:'';position:absolute;inset:0;pointer-events:none;opacity:0;
  background:radial-gradient(150% 150% at var(--mx,50%) var(--my,50%),var(--tile-sheen),rgba(255,255,255,0.04) 28%,rgba(255,255,255,0.012) 55%,transparent 82%);
  transition:opacity .45s var(--ease)}
.tilt::after{content:'';position:absolute;top:-1px;left:-1px;right:-1px;bottom:-1px;
  border-radius:var(--r-tile);box-shadow:0 0 32px var(--border-hi);
  opacity:0;z-index:-1;pointer-events:none;transition:opacity .5s var(--ease)}
.tilt>*{position:relative;z-index:1}
.tilt:hover{border-color:var(--border-hi);background:var(--card-hi)}
.tilt:hover::before{opacity:0.55}
.tilt:hover::after{opacity:0.4}

/* ═══ LOADER ════════════════════════════════════════════════════════════
   An ANTI-FLASH PLATE, nothing more. The opening film runs on the hero stage,
   not in here (see intro.css's header note) — intro.js dismisses this on its
   first frame, once the runtime hidden states are set, and it fades out black
   onto black. It stays empty for its whole life.
   It only exists when the page can animate (html.rzv-anim, decided pre-paint
   by the inline head script): with motion off nothing is hidden, so there is
   nothing to cover. index.html carries an inline 2.5s fallback that forces
   .is-done if intro.js never claims it, so a missing or failed intro.js can
   never leave the site behind a black rectangle. ═══ */
#loader{
  position:fixed;inset:0;z-index:9500;
  background:var(--bg);
  display:flex;align-items:center;justify-content:center;
  transition:opacity .9s var(--ease),visibility 0s linear .9s;
}
html:not(.rzv-anim) #loader{display:none}
#loader.is-done{opacity:0;visibility:hidden;pointer-events:none}

/* ═══ HEADER ════════════════════════════════════════════════════════════
   Fixed glass bar. view-transition-name:appheader (set inline in the HTML, as
   in the dashboard) keeps it as one persistent element across the index →
   case-page navigation while the rest of the document dips. ═══ */
.site-header{
  position:fixed;top:0;left:0;right:0;z-index:500;
  height:var(--header-h);display:flex;align-items:center;
  /* 12 Sep, Haseeb: "I don't like the border line under header. Remove it." —
     the hairline and the .scrolled rule that revealed it are both gone. */
  transition:opacity 0.9s var(--ease),transform 0.9s var(--ease);
}
/* PROGRESSIVE BLUR, not a flat tinted bar — "no bg color. instead do the
   progressive blur", his words. The flat background and the uniform blur
   both went; what replaces them is a single backdrop-filter on ::before,
   masked so it is strongest at the very top edge and fades to nothing by
   the header's own bottom — content underneath reads as receding into frost
   rather than meeting a hard-edged glass bar.
   ON ::before, NOT ON .site-header ITSELF, and that is a real improvement
   against TRAP 1, not just a different way of doing the same thing: the
   filter used to sit on .site-header, an ANCESTOR of everything inside it
   (.header-inner and whatever it ever grows); here it sits on a SIBLING
   pseudo-element instead, so a future mix-blend-mode child of .header-inner
   would not trigger the iOS black-box bug this file warns about elsewhere —
   sibling, not ancestor. z-index -1 is relative to .site-header's OWN
   stacking context (position:fixed + z-index:500 already makes one), so it
   paints behind .header-inner's normal-flow content with no z-index needed
   there. */
.site-header::before{
  content:'';position:absolute;inset:0;z-index:-1;
  backdrop-filter:blur(22px) saturate(1.35);-webkit-backdrop-filter:blur(22px) saturate(1.35);
  -webkit-mask-image:linear-gradient(to bottom, black 0%, black 45%, transparent 100%);
  mask-image:linear-gradient(to bottom, black 0%, black 45%, transparent 100%);
}
/* The opening cinematic owns the screen chrome-free: intro.js veils the
   header (RZV.veilHeader) and the first return to dark after the white
   phase — the constellation collapse — unveils it for good (nav.js). */
html.hdr-veil .site-header{opacity:0;transform:translateY(-12px);pointer-events:none}
/* position:relative anchors .brand's absolute centering below — the one
   thing that lets the wordmark sit at the header's true midpoint regardless
   of how wide its two flanking siblings (the pearl, the burger) are. */
.header-inner{display:flex;align-items:center;justify-content:space-between;gap:18px;position:relative}

/* "Header on desktop and mobile.: pearl top left. Logo middle. Menu hamburger
   right.", his words — three independent zones, not a wordmark-and-its-dot
   pinned together. The pearl used to sit AFTER "RZV" as the word's own full
   stop ("pearl dot sits after RZV. header", an earlier brief); it is now a
   direct .header-inner child in its own right, first in markup so space-between
   parks it at the left edge, with .burger — the last child — parked at the
   right. .brand sits between them but OUT of that flex flow entirely: centering
   it by flex alone would read as centered only when the pearl and the burger
   happened to be the same width, which they are not (9px vs 42px), so it is
   absolutely centered on the header instead, independent of both. */
.brand{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%)}
/* The dot IS the pearl — same material as the hero's, 9px. It arrives with
   the header itself, when nav.js unveils at the constellation collapse. */
.brand-pearl{--pearl-size:9px;--pearl-glow:0.9}
/* "change rzv to bizmo all caps black font" — CORRECTION: an earlier pass
   here read "bizmo" as a typo for "big mono" and guessed at generic style
   properties instead. It is not a typo — Bizmo is a real installed family
   (see the @font-face above) and this rule now actually loads it, at its
   real Black cut, rather than approximating "black" as font-weight on
   var(--mono). font-size and letter-spacing are back to their pre-guess
   values (13px, normal) rather than the invented 16px/0.05em: those were
   sized for Space Mono's metrics and there is no basis yet for what Bizmo
   wants at this spot — his to tune once he can see it rendering. Falls back
   to var(--mono) if the font ever fails to load, same fallback chain
   .rz-word (intro.css) uses for its own Bizmo rule — keep both in step.
   color stays var(--text), NOT a literal black: see PHASE-LIGHT above for
   why hardcoding #000 would make the nav illegible in the (far more common)
   dark phase. */
/* THE WORD, top-left: RZV//STUDIO in the house overline voice, the header's
   left zone in the 2 Sep reference (word · big mark · MENU =). */
.hdr-word{color:var(--text);white-space:nowrap;letter-spacing:0.22em;margin:0}   /* solid white, 2 Sep; 12 Sep: reads rzv.studio at every width — uppercase like every other .overline on the site. For lowercase, add text-transform:none here. */
.site-header .header-inner{max-width:var(--wrap-hero)}
@media (max-width:560px){
  .brand .brand-mark{width:45px;height:13px}   /* 12 Sep, Haseeb: smaller on mobile. 69×20 on desktop; .brand outranks the base rule below */
}
/* THE MARK. Haseeb's logo (assets/img/rzv-mark.svg, 459×141 — the 2 Sep cut with its diamond full stop) replaces the
   Bizmo "RZV" wordmark, 1 Sep 2026. Painted as a CSS mask over currentColor
   rather than an <img> or an inline <svg>: one 7KB file for all 28 pages, and
   it takes the header's text colour in BOTH phases for free — an <img> would
   be one baked fill, an inline svg would be 7KB of markup per page. Height
   is the old wordmark's 13px; width follows the file's own ratio (3.26). */
/* 11 Sep: header mark is now assets/img/rzv-logo.svg (197×57), picked in /lab/logos/ — "option 1 at 20px height". */
.brand-mark{display:inline-block;width:69px;height:20px;color:var(--text);
  /* PEARL (3 Sep): the header mark takes the .pearl material through its mask */
  background:
    radial-gradient(circle at 32% 26%, rgba(255,255,255,0.95), rgba(255,255,255,0) 58%),
    linear-gradient(120deg, rgba(129,140,248,0.34), rgba(244,114,182,0.26) 32%, rgba(34,211,238,0.30) 62%, rgba(167,139,250,0.32) 95%),
    linear-gradient(160deg,#ffffff,#e9ecf6);
  background-size:100% 100%, 240% 240%, 100% 100%;
  animation:iriDrift 7s ease-in-out infinite;
  -webkit-mask:url(/assets/img/rzv-logo.svg) center/contain no-repeat;
  mask:url(/assets/img/rzv-logo.svg) center/contain no-repeat;vertical-align:middle}

.nav-center{position:relative;display:inline-flex;gap:2px;padding:3px;
  background:var(--surface);border:1px solid var(--border);border-radius:var(--r-pill);
  backdrop-filter:blur(20px);-webkit-backdrop-filter:blur(20px)}
/* Fixed px height, not inherited line-height, so the pill's geometry is exact
   and reproducible (dashboard header.html makes the same choice). */
.nav-link{position:relative;z-index:1;min-width:96px;height:34px;
  display:inline-flex;align-items:center;justify-content:center;
  font-size:11.5px;font-weight:500;letter-spacing:-0.005em;padding:0 10px;
  border-radius:var(--r-pill);color:var(--muted);transition:color .3s var(--ease)}
.nav-link:hover:not(.active){color:var(--text)}
.nav-link.active{color:var(--bg)}
/* Position + width are written by nav.js off the live link geometry (the
   dashboard hardcodes them; here the labels differ in width and the nav is
   built once, so measuring is both safer and shorter). */
.nav-pill{position:absolute;top:3px;bottom:3px;left:3px;width:96px;border-radius:var(--r-pill);
  background:var(--text);z-index:0;opacity:0;
  transition:left .38s var(--ease-spring),width .38s var(--ease-spring),opacity .3s var(--ease)}
.nav-center.has-active .nav-pill{opacity:1}

.nav-meta{display:flex;align-items:center;gap:10px}
.nav-meta-label{color:var(--dim)}

/* ═══ HERO ══════════════════════════════════════════════════════════════ */
/* min-height was svh-only with no vh fallback at all, once — the one real gap
   an earlier pass found rather than introduced: a pre-15.4 Safari would see
   an invalid declaration here and #hero would size to content instead of the
   viewport. vh stays first so a browser without lvh keeps the old (if
   toolbar-jittery) behaviour rather than none.
   svh → lvh: "prevent the screen resizing on mobile — open for whichever
   [viewport height] is largest", his words — see body{}'s note above for the
   full svh-vs-lvh trade. #hero is the one place that trade is most visible:
   flex + justify-content:center means the copy is centred IN THE BOX, not on
   screen, so at 100lvh with the toolbar still expanded on first paint the
   centre sits a few px lower than true screen-centre; the instant the reader
   scrolls and the toolbar retracts, the box's real height and the visible
   viewport converge and the centring is exact. The alternative — 100svh — is
   exact on first paint but leaves a bare gap below the hero once the toolbar
   retracts, which is the resize/jump he was asking to prevent. */
#hero{min-height:100vh;min-height:calc(var(--vh,1vh) * 100);display:flex;flex-direction:column;justify-content:center;
  padding-top:calc(var(--header-h) + clamp(60px,10vh,120px));padding-top:calc(var(--header-h) + clamp(60px,10lvh,120px));
  padding-bottom:clamp(48px,8vh,96px);padding-bottom:clamp(48px,8lvh,96px)}
/* intro.js draws the pearl + its dotted orbit rings in here. Full-bleed and
   inert so the copy above it stays selectable. */
.hero-stage{position:absolute;inset:0;z-index:0;pointer-events:none;overflow:hidden}
/* The headline and CTA live in #hero (index.html), but their layout does NOT
   live here: .hero-inner / .hero-copy / .hero-title moved to intro.css, which
   pins .hero-inner to the viewport while the film runs and so has to own the
   whole box. See intro.css's own note at its .hero-inner rule. */

/* ═══ #constellation ════════════════════════════════════════════════════
   The section's box, and nothing else. Every .cn-* rule that gates on
   html.constellation-live now lives in constellation.css, with the class that
   switches it. ═══ */
/* The pre-claim box only. Once constellation.js claims the section,
   constellation.css's own .constellation-stage rule (THE RUNWAY) replaces
   every one of these (sticky, 100lvh, min-height:0). No perspective /
   transform-style here: the stage went flat —
   "there is no 3D here at all any more" (constellation.css:80) — and the two
   declarations existed purely to be overridden. */
.constellation-stage{position:relative;width:100%;min-height:clamp(420px,62vh,720px);min-height:clamp(420px,62lvh,720px)}

/* 12 Sep, Haseeb: "reduce gap between ven and portfolio so the dot drops
   behind a portfolio thumbnail" — and the same trim underneath, to bring the
   cube's entrance up against the last row. .section's 126px each side was air
   the venn's ending had to cross before it met anything. */
#work{padding-top:clamp(28px,3.4vw,56px);padding-bottom:clamp(28px,3.4vw,56px)}

/* ═══ #work ═════════════════════════════════════════════════════════════ */
.work-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:clamp(22px,3vw,44px)}
.work-tile{display:flex;flex-direction:column;gap:16px;position:relative}
/* Editorial stagger — the right-hand tile of each pair drops, so the grid
   reads as a column pair rather than a table. Stamped in markup as
   .work-tile--drop (11 Sep) rather than nth-child(even): full-width tiles
   shift the parity, and the drop landed on the left tile. Collapsed on one column. */
.work-grid>.work-tile--drop{margin-top:clamp(0px,6vw,90px)}
/* #clients — logo marquee under the offer doors */
/* 12 Sep, Haseeb: "add more spacing after the logos marquee bottom of page" —
   the strip is the last thing before the footer, so the room goes under it. */
.clients{padding-block:clamp(24px,4vw,56px) clamp(64px,9vw,132px)}
.clients-marquee{display:flex;overflow:hidden;-webkit-mask-image:linear-gradient(90deg,transparent,#000 12%,#000 88%,transparent);mask-image:linear-gradient(90deg,transparent,#000 12%,#000 88%,transparent)}
.clients-track{list-style:none;margin:0;padding:0;display:flex;flex:0 0 auto;align-items:center;animation:rzvClients 60s linear infinite}
.clients-track li{flex:0 0 auto;width:clamp(110px,12vw,170px);margin-right:clamp(12px,2vw,32px)}
.clients-track img{display:block;width:100%;height:auto;opacity:.55;transition:opacity .4s ease}
.clients-track li:hover img{opacity:1}
.clients-marquee:hover .clients-track{animation-play-state:paused}
@keyframes rzvClients{to{transform:translateX(-100%)}}
@media (prefers-reduced-motion:reduce){.clients-track{animation:none}}
html.rzv-no-motion .clients-track{animation:none}
.wt-media{position:relative;overflow:hidden;border-radius:var(--r-tile);
  aspect-ratio:4/3;background:var(--bg-2);border:1px solid var(--border);
  --mesh-blur:34px}

/* Full-width tile — an editorial break in the 2-up rhythm, stamped in
   index.html on the specific tiles chosen to read as a pause rather than a
   pair (currently Kash Akhtar and Ni'mah Health: 3rd and 6th of eight, so
   every run between breaks is an even number of ordinary tiles).
   THAT EVENNESS IS LOAD-BEARING, not decorative: `.work-grid` never sets
   grid-auto-flow, so placement is the default sparse algorithm. A
   grid-column:1/-1 item that arrives while the column cursor sits at
   column 2 (i.e. an ODD count of ordinary tiles since the last full-width
   tile or the start of the grid) cannot fit the current row and is pushed
   to a fresh one — leaving the column-2 cell it skipped permanently empty,
   a visible hole in the grid. Any future addition/removal of a
   `--full` tile must keep every in-between run even, or move to
   grid-auto-flow:dense — which was deliberately avoided here because dense
   can visually reorder tiles ahead of their DOM position, breaking the
   reading/tab order the eight links otherwise share with the mesh's
   `--breathe` phase-shift (work.js) and the entrance stagger's `perRow`
   grouping (both keyed to DOM index).
   margin-top is zeroed rather than left to nth-child(even): that stagger
   is a PAIR device (it drops a tile to offset it against the neighbour
   beside it) and means nothing to a tile with no neighbour — Ni'mah Health
   is nth-child(6), so without this it would inherit a stagger drop while
   sitting alone in its own full-width row.
   Specificity ties the nth-child rule above (3 classes each); this wins
   because it is later in the same file. */
.work-grid>.work-tile.work-tile--full{grid-column:1 / -1;margin-top:0}
/* The wider crop is desktop-only. Below 761px `.work-grid` is already a
   single column (mobile block, below), so a full-width tile is already the
   full measure like everything else — there is nothing left to break out
   of, and widening the aspect-ratio there would just be an unexplained
   difference in tile height. 16/9 matches this file's own .case-cover and
   work.css's .cg-full — the site's one existing "wide feature image" ratio,
   reused rather than inventing a second. */
@media(min-width:761px){
  .work-tile--full .wt-media{aspect-ratio:16/9}
}
/* The mesh is BOTH the tint over a real cover and the whole picture when the
   cover is missing — .wt-media.no-img (set by the img's inline onerror) just
   turns it up. No broken-image icon ever ships. */
.wt-media .mesh{opacity:0.85}
.wt-media.no-img .mesh{opacity:1}
.wt-img{position:relative;z-index:1;width:100%;height:100%;object-fit:cover;
  transition:opacity .6s var(--ease)}
.wt-name{font-size:var(--fs-sub);font-weight:300;letter-spacing:var(--ls-display);color:var(--text)}
.wt-meta{color:var(--dim)}
.wt-text{display:flex;flex-direction:column;gap:7px}

/* ═══ #purpose ══════════════════════════════════════════════════════════ */
.purpose-lines{display:flex;flex-direction:column;gap:clamp(8px,1vw,14px);max-width:22ch}
.purpose-projects{display:flex;flex-direction:column;margin-top:clamp(50px,7vw,100px)}
.pp-row{display:grid;grid-template-columns:minmax(200px,0.7fr) minmax(0,1.6fr);
  gap:clamp(20px,4vw,64px);padding:clamp(28px,3.4vw,44px) 0;border-top:1px solid var(--border)}
.pp-row:last-child{border-bottom:1px solid var(--border)}
.pp-name{font-size:var(--fs-sub);font-weight:300;letter-spacing:var(--ls-display);color:var(--text)}

/* ═══ #contact + footer ═════════════════════════════════════════════════ */
#contact{padding-bottom:clamp(70px,9vh,120px);padding-bottom:clamp(70px,9lvh,120px)}
/* The aurora host, anchored bottom-left and pushed a little off-canvas so the
   composition's brightest corner sits behind the CTA rather than beside it.
   aspect-ratio is mandatory, not styling — it is what keeps the ratio-locked
   blob arrangement in proportion (see .au). The mask fades the top edge out so
   the wash dies into the page instead of ending on a line. */
#contact > .au{
  left:0;bottom:0;width:min(760px,94%);aspect-ratio:5/4;
  --au-o:0.34;
  transform:translateX(-16%);
  -webkit-mask-image:linear-gradient(to top,#000 0%,rgba(0,0,0,0.74) 48%,transparent 96%);
  mask-image:linear-gradient(to top,#000 0%,rgba(0,0,0,0.74) 48%,transparent 96%);
}
/* REQUIRED, not defensive. .au is a positioned sibling; an unpositioned block
   paints BELOW every positioned sibling regardless of source order, so the
   copy would sit under the wash. This is portal.css:283's exact lesson.
   .wrap already carries these — restated here so the constraint is visible at
   the point it matters and survives any future edit to .wrap. */
#contact > .wrap{position:relative;z-index:1}
.contact-inner{display:flex;flex-direction:column;gap:clamp(26px,3.4vw,44px);align-items:flex-start}
.contact-line{max-width:20ch}
.site-footer{border-top:1px solid var(--border);padding:clamp(52px,6vw,80px) 0 clamp(30px,4vw,44px)}
/* 12 Sep, Haseeb: "on mobile, end footer about 150px spacing so footer bg logo
   can be seen clearly and last thing on the footer." The watermark is absolutely
   placed at the foot of the section, so the room has to come from the section's
   own padding — this is what it sits in. */
@media(max-width:760px){ .site-footer{padding-bottom:180px} }   /* 12 Sep: was 150 — the mark is 115px tall on a phone and needs clear air above it */
/* 12 Sep, Haseeb: "make it 1/4 1/4 1/4 1/4; social icons, strategy menu,
   design menu, marketing menu." */
.footer-cols{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:clamp(26px,3vw,48px)}
.ft-col--social .ft-social-icons{display:flex;gap:10px;flex-wrap:wrap}
/* Three columns (8 Sep 2026: Strategy · Design · Marketing). Under 760px they
   stack even without JS; with JS nav.js turns them into the accordion below. */
@media(max-width:760px){.footer-cols{grid-template-columns:1fr}}
.ft-col > .overline > .ft-head{color:var(--text);transition:color .25s var(--ease)}   /* 12 Sep: "make menu headers white" */
.ft-col > .overline > .ft-head:hover{color:var(--muted)}
.ft-col{display:flex;flex-direction:column;gap:14px}
.ft-col ul{display:flex;flex-direction:column;gap:9px}
.ft-link{font-size:var(--fs-xs);color:var(--muted);transition:color .25s var(--ease)}
.ft-link:hover{color:var(--text)}
.footer-base{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:20px;
  margin-top:clamp(46px,6vw,80px);padding-top:26px;border-top:1px solid var(--border)}
.ft-socials{display:flex;gap:8px}
.ft-social{padding:8px 16px;border-radius:var(--r-pill);border:1px solid var(--border);
  background:var(--surface);color:var(--muted);
  font-family:var(--mono);font-size:var(--fs-label-s);font-weight:700;letter-spacing:0.12em;text-transform:uppercase;
  transition:color .25s var(--ease),border-color .25s var(--ease),transform .25s var(--ease-spring)}
.ft-social:hover{color:var(--text);border-color:var(--border-hi);transform:translateY(-2px)}
/* 12 Sep: "move for the dreamers and doers to the bottom left. copyright stuff
   stays on the right." They are two children of .footer-base now (which already
   spaces its children apart), so the .ft-signoff pair that used to hold them
   together is gone. */
.ft-dreamers{margin-right:auto}

/* ── 10 Sep 2026, his footer layout ────────────────────────────────────────
     lets talk .......... cta
     1/2 logo | 1/2 (1/3 1/3 1/3) footer menu
     social icons ....... meta
   Three rows, each a left/right pair. The mark took the left half of the middle
   row from the bottom row, and the socials came down out of "Let's talk" to sit
   opposite the sign-off. On a phone every row stacks. ── */
.ft-mid{display:grid;grid-template-columns:1fr 1fr;gap:clamp(26px,4vw,64px);
  align-items:start;margin-bottom:clamp(34px,5vw,64px)}
.ft-mid > .ft-mark-shape{width:min(100%,clamp(140px,18vw,260px));margin:0}
.footer-base{display:flex;align-items:center;justify-content:space-between;
  gap:clamp(18px,3vw,40px);flex-wrap:wrap}
.footer-base > .ft-social-icons{display:flex;gap:8px;flex:none}
@media(max-width:760px){
  .ft-mid{grid-template-columns:1fr;gap:28px}
  .footer-base{flex-direction:column;align-items:flex-start;gap:22px}
}

/* ═══ CASE PAGES (work/*.html) ══════════════════════════════════════════
   Presentational only — the WORK agent duplicates work/_template.html and
   fills it. `view-transition-name:workhero` on .case-cover is one half of the
   shared-element morph; the other half is stamped onto the clicked tile's
   .wt-media at click time (see the contract comment in work/_template.html). ═══ */
.case-main{padding-top:var(--header-h)}
.case-hero{padding:clamp(48px,7vh,96px) 0 clamp(30px,4vw,52px);padding:clamp(48px,7lvh,96px) 0 clamp(30px,4vw,52px)}
.case-title{max-width:18ch}
.case-intro{margin-top:clamp(22px,3vw,36px);max-width:62ch;font-size:var(--fs-body-lg);
  font-weight:300;letter-spacing:-0.015em;line-height:1.55;color:var(--muted)}
.case-cover{position:relative;overflow:hidden;border-radius:var(--r-tile);
  aspect-ratio:16/9;background:var(--bg-2);border:1px solid var(--border);--mesh-blur:60px}
.case-cover .wt-img{width:100%;height:100%;object-fit:cover}
.case-meta{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));
  gap:clamp(18px,2.4vw,36px);padding:clamp(30px,4vw,50px) 0;
  border-top:1px solid var(--border);border-bottom:1px solid var(--border);
  margin:clamp(38px,5vw,64px) 0}
.cm-item{display:flex;flex-direction:column;gap:9px}
.cm-value{font-size:var(--fs-meta);color:var(--text)}
.case-body{display:flex;flex-direction:column;gap:clamp(46px,6vw,84px);max-width:70ch}
.case-block{display:flex;flex-direction:column;gap:16px}
.case-block p + p{margin-top:12px}
.case-figure{margin:clamp(38px,5vw,70px) 0;border-radius:var(--r-tile);overflow:hidden;
  border:1px solid var(--border);background:var(--bg-2)}





/* ═══ REDUCED MOTION ════════════════════════════════════════════════════
   Freeze, don't remove (trap 3): the pearl keeps its iridescent stack and the
   mesh keeps its blooms, they simply stop travelling. Anything that was only
   ever visible mid-animation must therefore already be legible at rest — which
   is why nothing on this page starts at opacity:0 in CSS. ═══ */
@media(prefers-reduced-motion:reduce){
  html:focus-within{scroll-behavior:auto}
  *,*::before,*::after{
    animation-duration:.001ms!important;animation-iteration-count:1!important;
    transition-duration:.001ms!important;scroll-behavior:auto!important;
  }
  .pearl,.mb,.aurora .ab,.btn-primary::before{animation:none}
  .mesh{transition:none}
  /* RZV.aurora never starts a timeline here, so the blobs are permanently
     static — a promoted layer per blob costs memory and buys nothing. */
  .aurora .ab{will-change:auto}
}
/* Same freeze when the header switch is off, whatever the OS says. nav.js sets
   html.rzv-no-motion. */
html.rzv-no-motion .pearl,
html.rzv-no-motion .mb,
html.rzv-no-motion .aurora .ab,
html.rzv-no-motion .btn-primary::before{animation:none}
html.rzv-no-motion .aurora .ab{will-change:auto}

/* ═══ MOBILE (≤760px) ═══════════════════════════════════════════════════
   Single column, and painted-aurora-safe: every backdrop-filter that could
   sit above a mix-blend-mode child is removed and the blends go with it (trap
   1 — the iOS black box). The surfaces become genuinely opaque instead of
   translucent, which is also simply cheaper on a phone GPU.

   .site-header IS THE ONE EXCEPTION NOW — "When I asked you to check menu
   bg I meant header bg [not the mobile menu panel]", his words, extending
   the desktop progressive-blur ask to mobile too. It gets to keep its blur
   here because it no longer meets trap 1's actual precondition: the filter
   sits on .site-header::before, a SIBLING of .header-inner, not an ancestor
   of it (see the ::before's own comment above) — and .header-inner has no
   mix-blend-mode descendant regardless, checked directly. The GPU-cost
   argument still applies exactly as much as it did before; he's asked for
   it anyway. */
@media(max-width:760px){
  :root{--header-h:62px}

  /* Half the blobs are display:none at this width and the blend strip goes
     opaque — the host widens and brightens to compensate (aurora spec §2). */
  #contact > .au{--au-o:0.46;width:120%;aspect-ratio:1/1}

  .nav-center,.glass,.btn,.tilt{
    backdrop-filter:none;-webkit-backdrop-filter:none;
  }
  .glass{background:var(--bg-2)}
  /* :not(.btn-ghost) — the ghost's aurora IS its surface, so flattening it to
     --surface-hi here would delete the button rather than de-blur it. */
  .btn:not(.btn-ghost){background:var(--surface-hi)}
  .tilt{background:var(--bg-2)}
  /* No blend group anywhere near a blurred ancestor. Alphas go up because a
     screen-blended blob and a painted one are not the same brightness. */
  .mb,.aurora .ab{mix-blend-mode:normal;opacity:0.72}
  .mesh{--mesh-blur:38px}
  .grain{opacity:0.035}

  /* Six painted, blurred, animated layers is a real cost on a phone. Three of
     them carry the composition (indigo / periwinkle / cyan); the violet,
     magenta and second indigo are the depth pass and are the ones to lose.
     Dropped, NOT resized — the survivors keep their authored geometry, per the
     ratio-lock rule on .aurora. */
  .au .aurora .ab.a4,.au .aurora .ab.a5,.au .aurora .ab.a6{display:none}
  .au .aurora .ab{will-change:auto}

  .nav-link{min-width:0;padding:0 13px;font-size:11px}
  .nav-pill{width:auto}
  .nav-meta-label{display:none}

  .contact-ctas{gap:12px}
  .contact-ctas .btn{flex:1 1 auto;justify-content:center}

  .work-grid{grid-template-columns:1fr;gap:34px}
  .work-grid>.work-tile--drop{margin-top:0}
  .wt-media{aspect-ratio:3/2}

  .pp-row{grid-template-columns:1fr;gap:12px}
  .case-cover{aspect-ratio:4/3}
  .footer-base{flex-direction:column;align-items:flex-start}
}
@media(max-width:460px){
  .nav-link{padding:0 10px;font-size:10.5px}
}

/* The glass run (index.html): a plain block host for the pinned solid. */
.glass-run{display:block;position:relative}
/* 6 Sep: air between the portfolio and the marquee. It was .glass-run's own
   padding-top, back when the host STARTED at the marquee; 9 Sep the host was
   extended up to wrap #work, so the identical gap moved onto the marquee's
   margin — same distance, same place on the page, and the run's old top edge
   is still recoverable as (#chips top − this margin), which is exactly what
   glass.js's HOME_RUN measures its size ramp and its morph against. */
/* 12 Sep, Haseeb: "tighten the gap below portfolio and the marquee … make it
   so that the glass cube appears from behind the last portfolio item as we
   scroll down." 40vh of runway meant the cube began rising with the grid
   already off the top of the screen, so it never had anything to come out from
   behind. At 16vh the rise starts while the last row is still on screen — and
   the tiles are z-index 3 against the canvas's 2 (work.css), so it passes
   underneath them. The run's measured top is unchanged: it is (#chips top −
   this margin), which is the bottom of #work either way, so glass.js's
   HOME_RUN still measures its ramp and morph from the same edge. */
.glass-run > #chips{margin-top:calc(var(--vh,1vh) * 16)}
@media (max-width:760px){ .glass-run > #chips{margin-top:0} }   /* 7 Sep: no gap on phones — the band overhangs instead (glass.js PIN clamp) */


/* The footer's full-width mark. Edge to edge (outside .wrap), a little air
   above, the footer's own bottom padding below. */
/* THE SIGNATURE (3 Sep 2026): the mark small and centred with ample air,
   filled with the pearl's own iridescent gradient (the .pearl material,
   drifting on iriDrift) through a CSS mask of the logo file. Minimal. */
.ft-mark-shape{
  width:clamp(96px,9vw,132px);aspect-ratio:459/141;   /* in .footer-base, where the social pills were */
  -webkit-mask:url(/assets/img/rzv-mark.svg) center/contain no-repeat;
          mask:url(/assets/img/rzv-mark.svg) center/contain no-repeat;
  background:
    radial-gradient(circle at 32% 26%, rgba(255,255,255,0.95), rgba(255,255,255,0) 58%),
    linear-gradient(120deg, rgba(129,140,248,0.34), rgba(244,114,182,0.26) 32%, rgba(34,211,238,0.30) 62%, rgba(167,139,250,0.32) 95%),
    linear-gradient(160deg,#ffffff,#e9ecf6);
  background-size:100% 100%, 240% 240%, 100% 100%;
  animation:iriDrift 7s ease-in-out infinite;
}
html.rzv-no-motion .ft-mark-shape{animation:none}

/* ── LET'S TALK — the footer's opening row ─────────────────────────────── */
.ft-talk{
  display:flex;align-items:flex-end;justify-content:space-between;gap:clamp(20px,4vw,60px);
  padding-bottom:clamp(40px,6vw,80px);margin-bottom:clamp(40px,6vw,80px);border-bottom:1px solid var(--border);
}
.ft-talk-copy{display:flex;flex-direction:column;gap:14px;max-width:52ch}
.ft-talk-title{margin:0;font-size:var(--fs-xl);font-weight:300;letter-spacing:var(--ls-display);line-height:1}   /* asterisks read as emphasis, not italics — same call as the other heads */
.ft-talk-body{margin:0;color:var(--muted)}
.ft-talk-cta{
  flex:0 0 auto;display:inline-flex;align-items:center;gap:.55em;
  /* 7 Sep, Haseeb: "solid white buttons — 66% to 33% opacity left-to-right
     gradient with backdrop blur". */
  /* 7 Sep: material comes from .pearl (class="ft-talk-cta pearl"); only layout lives here */
  text-decoration:none;border-radius:999px;padding:.95em 1.55em;font-weight:500;letter-spacing:-0.01em;line-height:1;
  transition:transform .35s var(--ease),box-shadow .35s var(--ease);
}
.ft-talk-cta:hover{}   /* 12 Sep: the lift and the outer glow are gone; the roll is the whole hover */
/* 12 Sep, Haseeb: "right now the word shfits right on hover moving with the
   arrow - correct this." `span` caught the label too, now that the label IS a
   span (the roll). Only a DIRECT aria-hidden child moves — which is the arrow
   and nothing else: the roll's own hidden copy sits a level deeper. */
.ft-talk-cta > span[aria-hidden="true"]{transition:transform .35s var(--ease)}
.ft-talk-cta:hover > span[aria-hidden="true"]{transform:translateX(3px)}
@media(max-width:760px){
  .ft-talk{flex-direction:column;align-items:flex-start}
}

/* ── SUBHEAD — the small-medium mono header (3 Sep 2026) ────────────────
   Uppercase, light, letter-spaced Space Mono. Used on the personal-project
   titles and the portfolio thumbnail titles. Space Mono ships no 300, so
   "light" is its regular at a quiet size. */
.subhead{font-family:var(--mono);font-weight:500;text-transform:uppercase;letter-spacing:0.14em;   /* 12 Sep: was 400 */
  font-size:var(--fs-mono);line-height:1.4;color:var(--text)}

/* On the home page the footer sits inside the glass run. Its content (talk
   row, columns, socials, sign-off) stacks ABOVE the run's canvas; the mark
   strip stays below it, so the solid is behind everything but in front of
   the logo (3 Sep). */
.glass-run > .site-footer > .wrap{position:relative;z-index:3}
/* …and the mark strip stacks above the glass too: the solid is BEHIND the
   logo in the footer (3 Sep), showing through the gaps in the letterform. */
.glass-run > .site-footer > .ft-mark{position:relative;z-index:3}
/* The footer is its own stacking context (position:relative + z-index:1), so
   its children could not rise past the run's canvas (z 2) — the glass sat over
   the columns (3 Sep). The whole footer goes above the canvas instead. */
body[data-page] .glass-run > .site-footer{z-index:3}   /* outranks work.css's body[data-page] .site-footer{z-index:1} */


/* Social icons under "Let's talk" — Font Awesome Brands, inline. */
.ft-social-icons{display:flex;gap:10px;margin-top:4px}
.ft-social--ico{width:42px;height:42px;padding:0;display:inline-flex;align-items:center;justify-content:center;border-radius:50%}
.ft-ico{width:16px;height:16px;fill:currentColor;display:block}
html.rzv-no-motion .brand-mark{animation:none}

/* ── FOOTER COLUMNS AS AN ACCORDION ON MOBILE (4 Sep 2026) ────────────────
   Gated on html.ft-acc, which nav.js adds only on small screens with JS, so
   without JS (or on desktop) the six columns render exactly as before. */
@media(max-width:760px){
  html.ft-acc .ft-col{border-bottom:1px solid var(--border);gap:0}
  html.ft-acc .ft-col > .overline{
    position:relative;margin:0;padding:16px 28px 16px 0;cursor:pointer;color:var(--text);user-select:none;
  }
  html.ft-acc .ft-col > .overline::before,
  html.ft-acc .ft-col > .overline::after{content:'';position:absolute;right:2px;top:50%;width:12px;height:1px;background:var(--text);
    transform:translateY(-50%);transition:transform .35s var(--ease)}
  html.ft-acc .ft-col > .overline::after{transform:translateY(-50%) rotate(90deg)}
  html.ft-acc .ft-col.is-open > .overline::after{transform:translateY(-50%) rotate(0deg)}
  html.ft-acc .ft-col > ul{max-height:0;overflow:hidden;transition:max-height .45s var(--ease),padding .45s var(--ease);padding-bottom:0}
  html.ft-acc .ft-col.is-open > ul{max-height:420px;padding-bottom:16px}
  html.ft-acc .ft-col:not(.is-open) > ul > li{visibility:hidden}
  html.ft-acc .footer-cols{gap:0;grid-template-columns:1fr}
  /* the head is the toggle on the phone; its hub link goes inert so a tap opens the list */
  html.ft-acc .ft-col > .overline > .ft-head{pointer-events:none}
}

/* ═══ THE TOUCH SHELL ═════════════════════════════════════════════════════
   html.rzv-shell is set pre-paint by the inline head snippet on every page,
   on a coarse pointer only. iOS Safari collapses and expands its toolbar only
   when the DOCUMENT scrolls, so here the document is a fixed-height shell and
   the page scrolls inside #scroller (main + footer; everything under the fixed
   header). The bar stays expanded and the visible area is constant — the
   trade Haseeb chose ("I'd rather you fix the toolbar size").
   · fixed elements are unaffected; #constellation's sticky stage sticks
     against #scroller, its nearest scrolling ancestor;
   · every scroll lock that used to close the viewport (body.loading,
     body.svc-lock, html.wm-lock) closes #scroller instead — the html
     overflow is already hidden here, so the originals are inert;
   · overflow-x:hidden moves from html to #scroller with the scroll.
   A fine pointer never sees any of this: #scroller is a plain div. ═══ */
html.rzv-shell{height:100%;overflow:hidden}
html.rzv-shell body{height:100%;min-height:0;overflow:hidden}
html.rzv-shell #scroller{height:100%;overflow-y:auto;overflow-x:hidden;overscroll-behavior:contain}
html.rzv-shell:has(body.loading) #scroller,
html.rzv-shell:has(body.svc-lock) #scroller,
html.rzv-shell.wm-lock #scroller{overflow:hidden}
/* 25 Sep: the page's own atmosphere (the fixed, blurred, screen-blended mesh) is
   under a modal's plate for as long as a modal is open. Its three blobs animate
   forever, and on iOS every one of those frames is a full-screen blurred
   composite nobody can see. Held still while a modal is up. */
html.wm-lock .mesh .mb{animation-play-state:paused}

/* ── SECONDARY PILL (7 Sep 2026, Haseeb's spec, verbatim values). A glass
   ghost of the white pill: 5% white over a backdrop blur, white ink, the
   same shape. No hover box-shadow, no background animation. ── */
.btn-secondary{
  align-self:flex-start;margin-top:28px;
  display:inline-flex;align-items:center;gap:.55em;
  background:linear-gradient(90deg,rgba(250,250,250,0.05),rgba(250,250,250,0.05));
  /* 7 Sep: geometry = the shared button system (work.css §BUTTON SYSTEM); the 1.9em/2.45em pill was hero-scale */
  -webkit-backdrop-filter:blur(14px) saturate(1.2);backdrop-filter:blur(14px) saturate(1.2);
  color:#ffffff;text-decoration:none;
  border-radius:999px;padding:.95em 1.55em;font-size:var(--fs-btn);font-weight:500;letter-spacing:-0.01em;line-height:1;
  transition:transform .35s var(--ease),box-shadow .35s var(--ease);
}
.btn-secondary:hover,.btn-secondary:focus-visible{box-shadow:none;background:linear-gradient(90deg,rgba(250,250,250,0.05),rgba(250,250,250,0.05))}

/* ── 10 Sep 2026, Haseeb: "have the logo in footer full width covering the
   background in faded color like a watermark. do just for homepage to test."
   Then: "yeah much nicer like that" — so it is every page now. The mark leaves the middle row's left half and becomes a layer
   across the whole footer, so the three service columns take the full width
   and the mark sits behind everything at a watermark's weight. The pearl
   gradient goes with it — a watermark is one flat tone, not a lit object. ── */
.site-footer{position:relative;overflow:hidden}
.site-footer > .wrap{position:relative;z-index:1}
.ft-mid{grid-template-columns:1fr}
.ft-mid > .ft-mark-shape{
  position:absolute;left:0;right:0;bottom:0;
  width:100%;max-width:none;height:auto;aspect-ratio:459/141;
  z-index:0;pointer-events:none;
  background:rgba(255,255,255,0.038);
  animation:none;
}
@media(max-width:760px){
  /* 12 Sep, Haseeb (phone screenshot): "Logo should be at bottom in that empty
     space". The mark is placed against .wrap (the nearest positioned box), and the
     150px of room is the FOOTER's padding below .wrap — so bottom:-4% left it
     sitting on top of "For the dreamers & doers". It now hangs down into that
     padding: its foot 22px off the bottom of the page. */
  .ft-mid > .ft-mark-shape{bottom:calc(-180px + 24px)}
  /* "Add spacing after social icons" — the icons ran straight into Strategy's rule. */
  html.ft-acc .ft-col--social .ft-social-icons{padding-bottom:22px}
  /* 12 Sep, Haseeb (screenshot, a "?" on Follow's +): the icons are always shown —
     there is nothing to open — so Follow is a plain heading, not an accordion row. */
  html.ft-acc .ft-col--social > .overline{cursor:default;padding-right:0}
  html.ft-acc .ft-col--social > .overline::before,
  html.ft-acc .ft-col--social > .overline::after{content:none}
  /* "Remove the extra horizontal line" — Marketing's own rule already closes the
     list, so the base row's border-top was a second line 46px under it. */
  html.ft-acc .footer-base{border-top:0;padding-top:0;margin-top:32px}
}

/* ═══ PAGE TRANSITIONS — the depth dissolve (10 Sep 2026, Haseeb: option 1) ═══
   Native cross-document View Transitions, opted in on every page because every
   page loads this file. The browser holds the outgoing page on screen until the
   incoming one is ready to paint — with hover prefetch (the speculationrules
   block in each <head>) that is near-instant — then plays the pair below: the
   outgoing page sinks to 97%, blurs and fades; the incoming one rises a few
   pixels into focus. The header is its own named group with no animation, so
   it holds perfectly still across the cut. Chrome, Edge and Safari 18.2+;
   Firefox simply navigates. Off under reduced motion and the site's motion
   switch (html.rzv-no-motion). The portfolio and services modals use pushState,
   not navigation, so they are untouched by this. */
/* 12 Sep: navigation:none — the cross-document transition is retired in favour
   of the veil below. The header needs no view-transition-name now: it is the
   same markup on every page and the veil never covers it, so it simply stays. */
@view-transition{navigation:none}
/* 10 Sep 2026, Haseeb: "not seamless kinda jitters maybe slow things down".
   Two causes, both mine. The 10px blur: a filter on a full-page snapshot is
   re-rasterised every frame instead of being handed to the compositor, so the
   frames dropped. And the double exposure: both pages were half-transparent at
   once, moving differently, composited with the UA default plus-lighter blend —
   the overlap shimmered. Now: opacity and transform only (compositor-only), a
   normal blend, and a near-sequence rather than an overlap — the old page is
   mostly gone before the new one arrives — at about 1.1s end to end. */
/* 12 Sep, Haseeb: "theres still a blink to black … you must figure this out."
   THE VIEW TRANSITION IS THE PROBLEM, not its numbers. Every version of it
   blinked because the browser snapshots the outgoing page, and this site's
   first screen is WebGL — the lava hero, the glass solid, the ribbon — plus
   pinned and fixed layers. Those do not snapshot faithfully; the frame the
   transition starts on is not the frame that was on the screen, and no easing
   can hide a wrong first frame. Three attempts at tuning it says the mechanism
   is wrong for this page, so the mechanism goes.
   WHAT REPLACES IT — a veil this file owns, under the header:
     · arriving, html::before is black and fades away on a CSS animation. No JS
       in the path at all, so it cannot get stuck: even if every script fails,
       the animation still ends at transparent and pointer-events:none.
     · leaving, nav.js fades the same colour back IN and then navigates.
   Nothing is snapshotted, so nothing can snapshot wrong, and the header is
   simply not covered — the veil stops at z-index 400, the header sits at 500. */
@keyframes rzvVeilOut{from{opacity:1}to{opacity:0}}
html.rzv-anim::before{
  /* 12 Sep, Haseeb: "might be better to just fold the header into the dip to
     black and then on each page animate the header in the same way from home
     hero." So the veil goes OVER the header (500) instead of under it, and
     nav.js runs the header's own corner cascade on arrival — every page now
     assembles the way the home film assembles it. */
  content:'';position:fixed;inset:0;z-index:600;pointer-events:none;
  background:var(--bg,#08080A);
  animation:rzvVeilOut .52s cubic-bezier(0,0,.2,1) both;
}
/* The leave half. nav.js tweens the opacity; this is only the box. */
.pt-veil{position:fixed;inset:0;z-index:600;pointer-events:none;background:var(--bg,#08080A);opacity:0}

/* The page entrance's masks (12 Sep, work.js initPageEntrance). form.css and
   strategy.css carry the same two rules for their own pages; this is the copy
   every page can reach, since base.css is the one stylesheet all of them load.
   .rw-line is the whole-line variant: a block clip for small text that rises as
   one piece rather than word by word. */
/* ── PRE-ENTRANCE (12 Sep) ──────────────────────────────────────────────
   Haseeb: "we still have blinking initial load issues on oage load. header and
   bodty briefly flash." Both entrances hide their elements in JS, at
   DOMContentLoaded — but the veil clears on a clock of its own, so on any load
   where the veil finished first the reader saw the header and the hero PAINT,
   then be snatched away and animated. A state that must exist at the first
   paint cannot be written by a deferred script; it has to be CSS.
   Gated on html.rzv-anim, which the inline head script writes while the head is
   still parsing and never writes when JS is off — so a reader without JS gets
   the page exactly as it ships. Lifted by .hdr-in / .hero-in the moment the
   owning script takes the elements over, so from then on GSAP's inline values
   rule and its clearProps cannot drop anything back into hiding.
   THE ANIMATION IS THE SAFETY. If the script never arrives — a 404 on the
   bundle, a throw before it gets there — the hold expires on its own and the
   page is readable regardless. 8s, because index.html's film legitimately holds
   its header back for about five. */
/* The lede's tail line (services): smaller and quieter than the lede, on its
   own line under it — 12 Sep. */
/* Two classes on purpose: work.css sets .page-lede's size and loads after this
   file, so a single-class rule here would lose on order at equal weight. */
.page-hero .page-lede--note{font-size:var(--fs-body);color:var(--muted);margin-top:clamp(12px,1.6vw,20px);max-width:46ch}

@keyframes rzvPreHold{0%,99%{opacity:0}100%{opacity:1}}
html.rzv-anim:not(.hdr-in) .site-header .hdr-word,
html.rzv-anim:not(.hdr-in) .site-header .brand,
html.rzv-anim:not(.hdr-in) .site-header .burger,
html.rzv-anim:not(.hero-in) .page-hero .page-title,
html.rzv-anim:not(.hero-in) .page-hero .page-lede,
html.rzv-anim:not(.hero-in) .page-hero .ct-actions,
html.rzv-anim:not(.hero-in) .mi-lede,
/* 12 Sep, Haseeb: "portfolio flshes before the anim in begins, same issue as
   before you fixed". Same cause, one layer out: the tiles are hidden by JS at
   DOMContentLoaded, so on any load where the veil cleared first they PAINTED and
   were then snatched away. The hold covers them from the first frame too.
   It covers tiles BELOW the fold as well, which is correct: .hero-in lifts it
   the moment work.js owns the page, and by then each tile is either claimed by
   the entrance or hidden by revealItems for its own scroll reveal — whichever,
   the inline state takes over from here. */
html.rzv-anim:not(.hero-in) .work-grid > .work-tile,
html.rzv-anim:not(.hero-in) .svc-tile,
html.rzv-anim:not(.hero-in) .jn-timeline,
html.rzv-anim:not(.hero-in) .bw,
html.rzv-anim:not(.hero-in) .offer-door{animation:rzvPreHold 8s linear both}

.rw{display:inline-block;overflow:hidden;vertical-align:bottom;padding-bottom:.1em;margin-bottom:-.1em;-webkit-clip-path:inset(0);clip-path:inset(0)}
.rw-i{display:inline-block;will-change:transform}
.rw-line,.rw-line > .rw-i{display:block}
@media (prefers-reduced-motion:reduce){
  html.rzv-anim::before{animation-duration:.01s}
}
html.rzv-no-motion::before{display:none}   /* motion off: no veil, in or out */

/* ── 404 (11 Sep) ─────────────────────────────────────────────────────── */
.nf-wrap{text-align:center;padding-block:clamp(72px,16vh,200px)}
.nf-num{color:var(--dim);margin:0 0 14px}
.nf-title{margin:0 0 14px}
.nf-body{color:var(--muted);max-width:44ch;margin:0 auto clamp(24px,4vw,40px)}
.nf-ctas{display:flex;gap:12px;justify-content:center;flex-wrap:wrap;margin:0}
.nf-ctas .offer-cta{margin-top:0}

/* ═══ THE BUTTON ROLL (12 Sep 2026) ══════════════════════════════════════
   Haseeb: "remove box shadow / outer glow from any buttons. remove that little
   lift buttons currently have. instead make the animation button label slide up
   mask slide up bottom replace."
   Two copies of the label stacked inside a one-line clip: on hover both travel
   up exactly their own height, so the first leaves through the top as the second
   arrives from the bottom. Nothing moves but type — no shadow to re-raster, no
   box to lift — and the second copy is aria-hidden, so a screen reader still
   hears the label once. nav.js builds the markup (buildRoll), because the label
   is page copy and this file cannot reach it.
   Motion off: the copies are still stacked, the transition is not. */
.btn-roll{display:inline-block;overflow:hidden;height:1em;line-height:1;vertical-align:bottom}
.btn-roll-i{display:block;transition:transform .42s var(--ease)}
.btn:hover .btn-roll-i,.btn:focus-visible .btn-roll-i,
.offer-cta:hover .btn-roll-i,.offer-cta:focus-visible .btn-roll-i,
.ft-talk-cta:hover .btn-roll-i,.ft-talk-cta:focus-visible .btn-roll-i,
.cf-next:hover .btn-roll-i,.cf-next:focus-visible .btn-roll-i,
.cf-back:hover .btn-roll-i,.cf-back:focus-visible .btn-roll-i,
.cf-begin:hover .btn-roll-i,.cf-begin:focus-visible .btn-roll-i,
.purpose-cta:hover .btn-roll-i,.purpose-cta:focus-visible .btn-roll-i,
#cfSubmit:hover .btn-roll-i,#cfSubmit:focus-visible .btn-roll-i{transform:translateY(-100%)}
html.rzv-no-motion .btn-roll-i{transition:none}

/* ═══ BUTTONS (12 Sep 2026) ════════════════════════════════════════════════
   Haseeb: "ALL buttons should be uppercase mono medium weight with some letter
   spacing." One rule for every button-shaped thing on the site rather than a
   tweak per component, so a new button inherits the voice by being a button.
   Last in the file on purpose: .btn-primary and .offer-cta set their own
   weights at the same specificity, and this has to be the one that lands.
   The arrows inside them are spans and unaffected — uppercase does nothing to
   an arrow, and the tracking is inherited, which is what keeps the gap between
   word and arrow looking deliberate. */
.btn,.btn-primary,.btn-ghost,.btn-lg,
.offer-cta,.offer-cta--pay,.offer-calc-cta,
.ft-talk-cta,.cf-begin,.cf-next,.cf-back,#cfSubmit,
/* NOT .pf-seg-btn: the budget/timeline switches were deliberately put into the
   sans on 9 Sep — "use regular sans font here looks too confusing as mono" —
   and a blanket rule should not quietly reverse a decision that specific. */
.purpose-cta,.hero-cta,.jm-close,.wm-close{
  font-family:var(--mono);
  text-transform:uppercase;
  /* 12 Sep, Haseeb: "2px smaller and one weight heavier" — 13px against the
     pills' 15px token, at semibold. A real 600 face ships for it; without one
     the browser would round a 600 up to the 700 it has. */
  font-size:calc(var(--fs-btn) - 2px);
  font-weight:600;
  letter-spacing:0.12em;
}
