/* ═══════════════════════════════════════════════════════════════════════════
   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:'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.035em;
  --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}
body{
  font-family:'Inter',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:-0.04em;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)}
.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{transform:translateY(-2px);border-color:rgba(255,255,255,0.42)}
.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%)}
.aurora .ab.a3{width:98%;height:86%;left:37%;bottom:-31%;
  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;
  border-bottom:1px solid transparent;
  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%);
}
.site-header.scrolled{border-bottom-color:var(--border)}
/* 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 */
.site-header .header-inner{max-width:var(--wrap-hero)}
@media (max-width:560px){
  .hdr-word-tail{display:none}          /* RZV//STUDIO → RZV// */
  .brand .brand-mark{width:59px;height:17px}   /* 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)}

/* ═══ #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 */
.clients{padding-block:clamp(24px,4vw,56px)}
.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:-0.03em;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:-0.03em;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)}
.footer-cols{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:clamp(26px,3vw,48px)}
/* 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:inherit;transition:color .25s var(--ease)}
.ft-col > .overline > .ft-head:hover{color:var(--text)}
.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)}
.ft-signoff{display:flex;flex-wrap:wrap;gap:18px;align-items:center}

/* ── 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. */
.glass-run > #chips{margin-top:calc(var(--vh,1vh) * 40)}
@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:-0.035em;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{transform:translateY(-1px);box-shadow:0 14px 34px -18px rgba(255,255,255,0.55)}
.ft-talk-cta span{transition:transform .35s var(--ease)}
.ft-talk-cta:hover span{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:400;text-transform:uppercase;letter-spacing:0.14em;
  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}

/* ── 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){
  .ft-mid > .ft-mark-shape{bottom:-4%}
}

/* ═══ 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. */
@view-transition{navigation:auto}
.site-header{view-transition-name:appheader}
::view-transition-group(appheader),::view-transition-new(appheader){animation:none}
::view-transition-old(appheader){display: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. */
::view-transition-old(root),::view-transition-new(root){mix-blend-mode:normal}
::view-transition-old(root){animation:rzvDepthOut .5s cubic-bezier(.4,0,.6,1) both}
::view-transition-new(root){animation:rzvDepthIn .8s cubic-bezier(.22,1,.36,1) .32s both}
@keyframes rzvDepthOut{to{opacity:0;transform:scale(.985)}}
@keyframes rzvDepthIn{from{opacity:0;transform:translateY(10px)}}
@media (prefers-reduced-motion:reduce){
  ::view-transition-group(*),::view-transition-old(*),::view-transition-new(*){animation:none!important}
}
html.rzv-no-motion::view-transition-group(*),
html.rzv-no-motion::view-transition-old(*),
html.rzv-no-motion::view-transition-new(*){animation:none!important}

/* ── 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}
