/* ==========================================================================
   B VISUAL — shell.css
   Loader · Nav · Mobile menu · Footer · Page-transition overlay · Motion utilities
   No raw colour values. No box-shadow. Nothing under 400ms.
   ========================================================================== */

/* ==========================================================================
   0. Pre-paint states  (set synchronously by the inline head script)
   ========================================================================== */

/* First visit: hold the page until the loader hands over. */
html[data-loader="on"] body > .nav,
html[data-loader="on"] body > main,
html[data-loader="on"] body > .footer { visibility: hidden; }

/* Motion is armed: reveal targets start hidden. If the engine never boots,
   the head script flips data-motion to "off" and everything renders flat. */
html[data-motion="on"] [data-reveal] { opacity: 0; }
html[data-motion="on"] [data-reveal="mask"] { opacity: 1; clip-path: inset(0 100% 0 0); }

/* Lenis (smooth scroll) — the library's required rules, inlined so no
   third-party stylesheet is loaded. */
html.lenis, html.lenis body { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }
.lenis.lenis-stopped { overflow: hidden; }
.lenis.lenis-scrolling iframe { pointer-events: none; }

/* ==========================================================================
   1. Buttons
   ========================================================================== */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s1);
  border-radius: var(--r-ui);
  font-size: var(--t-micro);
  font-weight: var(--w-max);
  line-height: var(--lh-micro);
  letter-spacing: var(--tr-micro);
  text-transform: uppercase;
  padding: 14px var(--s3);
  transition: background-color var(--d-ui) var(--e-out),
              color var(--d-ui) var(--e-out),
              border-color var(--d-ui) var(--e-out);
  will-change: transform;
}

/* THE accent. The one pill radius on the site. */
.btn--primary {
  background: var(--lime);
  color: var(--ink);
  border-radius: var(--r-pill);
  padding-inline: var(--s4);
}
.btn--primary:hover { background: var(--paper); }

.btn--ghost {
  border: var(--hairline);
  color: var(--bone);
}
.btn--ghost:hover { border-color: var(--purple); color: var(--paper); }

/* ==========================================================================
   2. Nav
   ========================================================================== */

.nav {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: var(--z-nav);
  height: var(--nav-h);
  display: flex;
  align-items: center;
  border-bottom: 1px solid transparent;
  background: transparent;
  transition: height var(--d-ui) var(--e-out),
              background-color var(--d-ui) var(--e-out),
              border-color var(--d-ui) var(--e-out),
              backdrop-filter var(--d-ui) var(--e-out);
}

.nav.is-scrolled {
  height: var(--nav-h-sm);
  background: var(--ink-94);
  border-bottom-color: var(--hair);
  backdrop-filter: saturate(140%) blur(14px);
  -webkit-backdrop-filter: saturate(140%) blur(14px);
}

.nav__inner {
  width: 100%;
  max-width: var(--maxw);
  margin-inline: auto;
  padding-inline: var(--gutter);
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: var(--s3);
}

.nav__mark {
  display: inline-flex;
  align-items: center;
  justify-self: start;
  padding: var(--s1) 0;                 /* clear space >= crossbar height */
}
.nav__mark svg { height: var(--mark); width: auto; }
.nav__mark .mark-body { fill: var(--paper); transition: fill var(--d-ui) var(--e-out); }
.nav__mark .mark-edge { fill: var(--mint); transition: fill var(--d-ui) var(--e-out); }
.nav__mark:hover .mark-body { fill: var(--purple); }

/* Wordmark beside the mark. Masked rather than inlined: the supplied SVG
   hardcodes fill:#fff on all eight paths, which would put a raw colour
   outside tokens.css and make it untintable. A mask paints it with a token
   instead, lets it share the mark's hover, and keeps one cached file rather
   than ~4KB of path data repeated in six documents. Decorative — the
   anchor's aria-label already names the destination. */
.nav__logotype {
  height: calc(var(--mark) * .46);
  aspect-ratio: 392.815 / 64.256;
  margin-left: calc(var(--s2) - 2px);   /* optical, not mathematical */
  background-color: var(--paper);
  -webkit-mask: url("/assets/logo/logotype-white.svg") left center / contain no-repeat;
          mask: url("/assets/logo/logotype-white.svg") left center / contain no-repeat;
  transition: background-color var(--d-ui) var(--e-out);
}
.nav__mark:hover .nav__logotype { background-color: var(--purple); }

/* Below this the nav carries the burger and the CTA pill as well; the
   wordmark is the one element that can go without losing meaning, since
   the mark still identifies the brand and links home. */
@media (max-width: 620px) {
  .nav__logotype { display: none; }
}

.nav__links {
  display: flex;
  align-items: center;
  gap: var(--s5);
  justify-self: center;
}

.nav__link {
  position: relative;
  font-size: var(--t-micro);
  font-weight: var(--w-max);
  line-height: var(--lh-micro);
  letter-spacing: var(--tr-micro);
  text-transform: uppercase;
  color: var(--bone);
  padding-block: var(--s1);
  transition: color var(--d-ui) var(--e-out);
}
.nav__link::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 1px;
  background: var(--purple);
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform var(--d-ui) var(--e-out);
}
.nav__link:hover { color: var(--paper); }
.nav__link:hover::after,
.nav__link[aria-current="page"]::after { transform: scaleX(1); }
.nav__link[aria-current="page"] { color: var(--paper); }

.nav__end {
  display: flex;
  align-items: center;
  gap: var(--s2);
  justify-self: end;
}

/* Burger — mobile only */
.nav__burger {
  display: none;
  width: 40px;
  height: 40px;
  padding: 0;
  position: relative;
  border-radius: var(--r-ui);
}
.nav__burger span {
  position: absolute;
  left: 9px;
  width: 22px;
  height: 2px;
  background: var(--paper);
  transition: transform var(--d-min) var(--e-trans),
              opacity var(--d-min) var(--e-trans);
}
.nav__burger span:nth-child(1) { top: 16px; }
.nav__burger span:nth-child(2) { top: 23px; }
.nav__burger[aria-expanded="true"] span:nth-child(1) { transform: translateY(3.5px) rotate(45deg); }
.nav__burger[aria-expanded="true"] span:nth-child(2) { transform: translateY(-3.5px) rotate(-45deg); }

@media (max-width: 900px) {
  .nav__inner { grid-template-columns: auto 1fr; }
  .nav__links { display: none; }
  .nav__burger { display: block; }
  .btn--primary { padding-inline: var(--s3); }
}

/* Keep the CTA reachable at every viewport-height (M8) — it hides only while
   the full-screen menu is open, which carries its own pinned CTA. */
html[data-menu="open"] .nav__end .btn--primary { opacity: 0; pointer-events: none; }
.nav__end .btn--primary { transition: opacity var(--d-min) var(--e-out), background-color var(--d-ui) var(--e-out), color var(--d-ui) var(--e-out); }

/* ==========================================================================
   3. Mobile menu — full-screen overlay
   ========================================================================== */

.menu {
  position: fixed;
  inset: 0;
  z-index: var(--z-menu);
  background: var(--ink);
  background-image: var(--grad-field);
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  padding: calc(var(--nav-h) + var(--s8)) var(--gutter) var(--s8);
  clip-path: inset(0 0 100% 0);
  visibility: hidden;
  transition: clip-path var(--d-page) var(--e-trans), visibility 0s linear var(--d-page);
}
html[data-menu="open"] .menu {
  clip-path: inset(0 0 0 0);
  visibility: visible;
  transition: clip-path var(--d-page) var(--e-trans), visibility 0s linear 0s;
}

.menu__list { list-style: none; display: flex; flex-direction: column; gap: var(--s2); }
.menu__list li { overflow: hidden; }
.menu__link {
  display: block;
  font-size: var(--t-head);
  font-weight: var(--w-max);
  line-height: var(--lh-head);
  letter-spacing: var(--tr-head);
  text-transform: uppercase;
  color: var(--paper);
  transform: translateY(110%);
  transition: transform var(--d-reveal) var(--e-out);
  transition-delay: 0ms;
}
html[data-menu="open"] .menu__link {
  transform: translateY(0);
  transition-delay: calc(180ms + var(--i, 0) * var(--stagger));
}
.menu__link[aria-current="page"] { color: var(--purple); }

.menu__foot { display: flex; flex-direction: column; gap: var(--s4); }
.menu__meta { display: flex; flex-direction: column; gap: var(--s1); color: var(--mute); }
.menu__meta a { color: var(--bone); font-size: var(--t-body); }
.menu__foot .btn--primary { width: 100%; padding-block: 18px; }

/* ==========================================================================
   4. Footer
   ========================================================================== */

.footer {
  position: relative;
  border-top: var(--hairline);
  background-image: var(--surface-plum);
  padding-block: var(--section) var(--s6);
}

.footer__cta-row { display: block; margin-bottom: var(--s12); }
.footer__cta {
  display: block;
  font-size: var(--t-display);
  font-weight: var(--w-max);
  line-height: var(--lh-display);
  letter-spacing: var(--tr-display);
  text-transform: uppercase;
  color: var(--paper);
  transition: color var(--d-ui) var(--e-out);
}
.footer__cta:hover { color: var(--purple); }
.footer__cta-note { margin-top: var(--s3); color: var(--mute); max-width: 46ch; }

.footer__grid {
  display: grid;
  grid-template-columns: 1.4fr 1fr 1fr 1fr;
  gap: var(--s8) var(--s6);
  padding-top: var(--s8);
  border-top: var(--hairline);
}
@media (max-width: 900px) { .footer__grid { grid-template-columns: 1fr 1fr; } }
@media (max-width: 560px) { .footer__grid { grid-template-columns: 1fr; gap: var(--s6); } }

.footer__col { display: flex; flex-direction: column; gap: var(--s3); }
.footer__col .label { margin-bottom: var(--s1); }
.footer__list { list-style: none; display: flex; flex-direction: column; gap: var(--s2); }
.footer__list a, .footer__link {
  color: var(--bone);
  transition: color var(--d-ui) var(--e-out);
}
.footer__list a:hover, .footer__link:hover { color: var(--purple); }
.footer__mark svg { height: 40px; width: auto; }
.footer__mark .mark-body { fill: var(--paper); }
.footer__mark .mark-edge { fill: var(--mint); }
.footer__note { color: var(--mute); }
.footer__placeholder {
  display: inline-block;
  margin-left: var(--s1);
  padding: 3px 8px;
  border: var(--hairline);
  border-radius: var(--r-ui);
  color: var(--mute);
  font-size: var(--t-micro);
  font-weight: var(--w-max);
  letter-spacing: var(--tr-micro);
  text-transform: uppercase;
  line-height: var(--lh-micro);
  vertical-align: middle;
}

.footer__base {
  margin-top: var(--s10);
  padding-top: var(--s4);
  border-top: var(--hairline);
  display: flex;
  flex-wrap: wrap;
  gap: var(--s3) var(--s6);
  align-items: center;
  justify-content: space-between;
  color: var(--mute);
}

/* ==========================================================================
   5. Page-transition overlay
   ========================================================================== */

.tx {
  position: fixed;
  inset: 0;
  z-index: var(--z-transition);
  pointer-events: none;
  visibility: hidden;
}
.tx.is-active { visibility: visible; pointer-events: all; }
/* Two colour sheets lead the panel in, so the cover reads as a layered sweep
   instead of one slab. All three are full-bleed and move on translateY only. */
/* No transform here. GSAP owns the sheets' and panel's transform entirely
   (resetOverlay sets yPercent: 100). A translateY(100%) in CSS as well is not
   a harmless duplicate — GSAP reads it as the base and adds yPercent on top,
   parking them 200% down, so the "enter" only ever travelled from 200% below
   to 100% below and the whole transition played off-screen. */
.tx__sheet {
  position: absolute;
  inset: 0;
  display: block;
}
.tx__sheet--a { background: var(--purple); }
.tx__sheet--b { background: var(--plum); }

.tx__panel {
  position: absolute;
  inset: 0;
  /* Carries the mark between pages — plum, same as the loader. */
  background-image: var(--surface-plum);
  display: grid;
  place-items: center;
}

/* Corner brackets, same framing device as the intro loader. Out at the edges
   and well clear of the mark, so they never sit behind the logo's counter. */
.tx__crop { position: absolute; inset: var(--gutter); pointer-events: none; }
.tx__crop i {
  position: absolute;
  width: 30px;
  height: 30px;
  display: block;
  border: 0 solid var(--purple-32);
}
.tx__crop i:nth-child(1) { top: 0;    left: 0;  border-top-width: 1px; border-left-width: 1px; }
.tx__crop i:nth-child(2) { top: 0;    right: 0; border-top-width: 1px; border-right-width: 1px; }
.tx__crop i:nth-child(3) { bottom: 0; left: 0;  border-bottom-width: 1px; border-left-width: 1px; }
.tx__crop i:nth-child(4) { bottom: 0; right: 0; border-bottom-width: 1px; border-right-width: 1px; }

.tx__stage { display: grid; justify-items: center; gap: var(--s4); }

.tx__mark { opacity: 0; }
/* Was 56px. The mark is the subject of the transition, so it is sized like
   one — still a uniform scale, never stretched. */
.tx__mark svg { height: min(22vw, 132px); width: auto; }
.tx__mark .mark-body { fill: var(--purple); }
.tx__mark .mark-edge { fill: var(--mint); }

/* The destination, named while it is still being fetched. Rises inside its own
   mask, the same device the headline reveals use. */
.tx__label {
  display: block;
  overflow: hidden;
  color: var(--bone);
  text-transform: uppercase;
  letter-spacing: var(--tr-micro);
  font-size: var(--t-micro);
}
.tx__label b { display: block; font-weight: inherit; }

@media (max-width: 620px) {
  .tx__crop i { width: 20px; height: 20px; }
}

/* ==========================================================================
   6. Loader
   ========================================================================== */

.loader {
  position: fixed;
  inset: 0;
  z-index: var(--z-loader);
  display: grid;
  grid-template-rows: 1fr auto;
  align-items: center;
  overflow: hidden;
}
/* The panel is the thing that wipes away; the mark rides above it. */
.loader__panel {
  position: absolute;
  inset: 0;
  /* Brand moment, not a page surface: keep the plum. */
  background-image: var(--surface-plum);
}
.loader__stage, .loader__foot { position: relative; z-index: 1; }
/* stretch, not center: .loader sets align-items:center, which would collapse
   this box to the height of the mark. The pattern and the crop marks are
   positioned against this box, and the mask that punches the hole around the
   logo is centred on it — so the stage has to fill its grid row for the hole
   to land on the mark and for the brackets to frame the field. place-items
   keeps the mark itself centred inside the stretched box. */
.loader__stage {
  display: grid;
  place-items: center;
  align-self: stretch;
}
.loader__mark { position: relative; z-index: 2; width: min(52vw, 320px); }
.loader__mark svg { width: 100%; height: auto; overflow: visible; }
.loader__mark .mark-body { fill: var(--purple); }
.loader__mark .mark-edge { fill: var(--mint); }
/* NO non-scaling-stroke here. getTotalLength() reports user units (a 212-wide
   viewBox), but non-scaling-stroke resolves the dash pattern in screen pixels.
   The mark now renders at 320px, ~1.5x its viewBox, so the dash was a third
   shorter than the path and the last stretch of the outline never drew. Keeping
   the stroke in user space makes dash and path share one coordinate system.
   stroke-width is therefore in user units: ~3px at 320px, ~1.9px at 195px. */
.loader__mark .trace { fill: none; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.loader__mark .trace-body { stroke: var(--purple); }
.loader__mark .trace-edge { stroke: var(--mint); }

/* --- Loader furniture -----------------------------------------------------
   A masked geometric lattice and four corner brackets. Neither paints behind
   the mark: anything that does lifts the ground around the logo but not the
   ground seen through its counter, and the counter then reads as a charcoal
   bar cut into the shape. Both are plain painted elements moved with
   transform/opacity only — no animated filters, no animated masks, no
   per-frame repaints of anything costly. ------------------------------------ */


/* The lattice. Diagonals at the angle of the mark's own blade, plus a fainter
   counter-diagonal. Painted once as repeating gradients — from here it is only
   transformed and faded, never re-rasterised.

   The mask is the important part, and it is STATIC (it never animates): a hole
   punched around the mark so no pattern paints anywhere the logo sits. Without
   it the ground around the silhouette lifts but the ground seen through the
   logo's counter does not, and the counter reads as a charcoal bar cut into
   the shape. The transparent radius clears the mark's half-diagonal at every
   size: 231px against 300px at desktop, 149px against 202px at 375px. */
.loader__pattern {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background-image:
    repeating-linear-gradient(45deg,  var(--purple-32) 0 1px, transparent 1px 46px),
    repeating-linear-gradient(-45deg, var(--purple-16) 0 1px, transparent 1px 46px);
  -webkit-mask-image: radial-gradient(circle at 50% 50%, transparent 0, transparent min(54vw, 300px), #000 min(78vw, 620px));
          mask-image: radial-gradient(circle at 50% 50%, transparent 0, transparent min(54vw, 300px), #000 min(78vw, 620px));
}

/* Corner brackets — crop marks on the field. Out at the edges, well clear of
   the mark, so they never interact with the counter. */
.loader__crop {
  position: absolute;
  inset: var(--gutter);
  z-index: 1;
  pointer-events: none;
}
.loader__crop i {
  position: absolute;
  width: 30px;
  height: 30px;
  display: block;
  border: 0 solid var(--purple-32);
}
.loader__crop i:nth-child(1) { top: 0;    left: 0;  border-top-width: 1px; border-left-width: 1px; }
.loader__crop i:nth-child(2) { top: 0;    right: 0; border-top-width: 1px; border-right-width: 1px; }
.loader__crop i:nth-child(3) { bottom: 0; left: 0;  border-bottom-width: 1px; border-left-width: 1px; }
.loader__crop i:nth-child(4) { bottom: 0; right: 0; border-bottom-width: 1px; border-right-width: 1px; }

@media (max-width: 720px) {
  .loader__crop i { width: 20px; height: 20px; }
}

.loader__foot {
  padding: var(--gutter);
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--s4);
}
.loader__label, .loader__count {
  font-size: var(--t-micro);
  font-weight: var(--w-max);
  line-height: var(--lh-micro);
  letter-spacing: var(--tr-micro);
  text-transform: uppercase;
  color: var(--mute);
}
.loader__count { color: var(--bone); font-variant-numeric: tabular-nums; }
.loader__bar {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 1px;
  background: var(--purple-16);
}
.loader__bar i {
  display: block;
  height: 100%;
  background: var(--purple);
  transform: scaleX(0);
  transform-origin: 0 50%;
}

/* ==========================================================================
   7. Motion utilities — reveal targets and marquee
   ========================================================================== */

/* A split heading carries two copies: the visible stack of masked line boxes
   (.bv-split, aria-hidden) and an unsplit accessible copy (.bv-a11y). */
.bv-split { display: block; }
.bv-line { display: block; overflow: hidden; }
.bv-line-in { display: block; will-change: transform; }
.bv-word { display: inline-block; }
/* Word reveal: each word rides in its own mask so it can rise from nothing. */
.bv-wordmask { display: inline-block; overflow: hidden; vertical-align: bottom; }
.bv-word-in  { display: inline-block; will-change: transform; }
.bv-a11y { user-select: none; -webkit-user-select: none; }

[data-marquee] {
  display: flex;
  width: 100%;
  overflow: hidden;
  user-select: none;
}
.marquee__track {
  display: flex;
  flex: none;
  align-items: center;
  gap: var(--s8);
  padding-right: var(--s8);
  will-change: transform;
}
.marquee__track > * { flex: none; }

/* ==========================================================================
   8. Reduced motion — every element in its final state. Not optional.
   ========================================================================== */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 1ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 1ms !important;
    scroll-behavior: auto !important;
  }
  html[data-motion] [data-reveal] { opacity: 1 !important; clip-path: none !important; }
  .bv-line-in { transform: none !important; }
  .bv-word-in { transform: none !important; }
  .menu__link { transform: none !important; }
  /* Parallax layers, card crops and the sticky-scroller stack: hold still.
     The scroller's copy steps stay fully legible rather than dimmed, since
     the dimming only made sense as a motion cue. */
  [data-parallax], .wcard__img, .scroller__img { transform: none !important; }
  .scroller__step { opacity: 1 !important; }
  .scroller__img { transition: none !important; }
  /* Decorative hero layers add nothing without movement. */
  .hero__frag, .hero__glow { display: none !important; }
  .loader { display: none !important; }
  html[data-loader] body > .nav,
  html[data-loader] body > main,
  html[data-loader] body > .footer { visibility: visible !important; }
}


/* ==========================================================================
   9. Horizontal track — shared by the Home selected-work carousel and the
   Work client roster. Lives here, not in a page stylesheet, because both
   pages use it and each page loads a different page-level sheet.
   ========================================================================== */

/* ==========================================================================
   CLIENT ROSTER — pinned horizontal tracks
   ========================================================================== */

/* A pinned section must fill the viewport, or the pin reads as a bug: the
   section's natural height is shorter than the screen, leaving a dead band
   under the cards for the whole time it is held. Filling the viewport and
   centring the content vertically makes the hold deliberate. */
.roster {
  /* svh, not vh: on iOS Safari vh is the *largest* viewport, so a 100vh
     pinned section sits partly under the browser chrome on first paint. */
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding-block: var(--s10);
}

.roster__head {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--s6);
  margin-bottom: var(--s8);
}
.roster__head .label { margin-bottom: var(--s3); }
.roster__hint { color: var(--mute); flex: none; }

/* The pinned viewport. overflow:hidden, not auto: while pinned, the track is
   driven by scroll position, so a native scrollbar would be a second,
   conflicting control over the same axis. */
.hscroll {
  overflow: hidden;
  padding-inline: var(--gutter);
}
.hscroll__track {
  display: flex;
  gap: var(--s4);
  width: max-content;
  will-change: transform;
}

/* Fallback when the track already fits, or reduced motion is on: never
   pinned (a pinned section that cannot move is a scroll trap), so give the
   visitor a real scrollbar instead. */
/* Touch / tablet / reduced-motion: a real swipeable strip with scroll-snap
   instead of a pinned sequence. Long pins are fragile on touch and a pinned
   section that cannot move is a scroll trap. */
.hscroll[data-hscroll-static="true"] {
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  -webkit-overflow-scrolling: touch;
  scroll-padding-inline: var(--gutter);
  scrollbar-width: none;
}
.hscroll[data-hscroll-static="true"]::-webkit-scrollbar { display: none; }
.hscroll[data-hscroll-static="true"] .hscroll__track {
  will-change: auto;
  /* Trailing space so the last card can snap clear of the right edge. */
  padding-right: var(--gutter);
}


/* ==========================================================================
   10. Neon cursor
   Hidden entirely from touch, coarse pointers and reduced-motion users —
   cursor.js never even builds it for them, and the native cursor is only
   suppressed once the element actually exists (.has-cursor).
   ========================================================================== */

.cursor {
  /* The blend MUST live here, not on the inner dot. GSAP transforms this
     element, and a transform creates a stacking context — so a blend applied
     to a child composites only against its siblings inside that context,
     which is nothing at all. Blending on the transformed element itself is
     what makes it difference against the actual page beneath. */
  mix-blend-mode: difference;
  position: fixed;
  top: 0; left: 0;
  width: 12px; height: 12px;
  margin: -6px 0 0 -6px;        /* centre on the pointer */
  z-index: 200;                  /* above nav, below nothing that matters */
  pointer-events: none;
  opacity: 0;
  will-change: transform;
}

.cursor__dot {
  display: block;
  width: 100%; height: 100%;
  border-radius: 50%;
  background: var(--neon);
  transition:
    width var(--d-ui) var(--e-out),
    height var(--d-ui) var(--e-out),
    margin var(--d-ui) var(--e-out),
    background-color var(--d-ui) var(--e-out);
}

/* Over anything with substance: grow and invert whatever is underneath.
   `difference` against the neon reads as a true inversion on photography
   and flips to dark over the lime CTA, so the cursor never disappears
   into the one element it most needs to be visible on. */
/* .is-invert now only changes SIZE — the inversion above is always on.
   Growing signals "this is interactive or substantial", which is a
   different job from the colour treatment. */
.cursor.is-invert .cursor__dot {
  width: 24px; height: 24px;
  margin: -6px 0 0 -6px;
}

.cursor.is-down .cursor__dot { width: 9px; height: 9px; margin: 1.5px 0 0 1.5px; }
.cursor.is-invert.is-down .cursor__dot { width: 19px; height: 19px; margin: -3.5px 0 0 -3.5px; }

/* Suppress the native cursor only where ours is genuinely present. */
html.has-cursor, html.has-cursor * { cursor: none; }
/* …except in text fields, where the caret carries real information. */
html.has-cursor input:not([type="checkbox"]):not([type="radio"]):not([type="button"]):not([type="submit"]),
html.has-cursor textarea { cursor: text; }
html.has-cursor.caret .cursor__dot { opacity: .35; }

@media (hover: none), (pointer: coarse) {
  .cursor { display: none; }
  html.has-cursor, html.has-cursor * { cursor: auto; }
}
@media (prefers-reduced-motion: reduce) {
  .cursor { display: none !important; }
  html.has-cursor, html.has-cursor * { cursor: auto !important; }
}

/* ==========================================================================
   11. Quick-form pop-up
   ========================================================================== */

.qf { position: fixed; inset: 0; z-index: 150; }
html.qf-open { overflow: hidden; }

.qf__scrim {
  position: absolute; inset: 0;
  background: rgba(10, 10, 13, .72);
  opacity: 0;
  transition: opacity var(--d-ui) var(--e-out);
}
.qf.is-open .qf__scrim { opacity: 1; }

.qf__panel {
  position: absolute;
  top: 50%; left: 50%;
  /* Wider on desktop so the headline sits on fewer lines and the whole form,
     submit included, fits without scrolling on a normal laptop. */
  width: min(46rem, calc(100vw - var(--s5) * 2));
  max-height: min(88svh, 52rem);
  overflow-y: auto;
  overscroll-behavior: contain;   /* don't chain scroll to the page behind */
  transform: translate(-50%, calc(-50% + 12px));
  opacity: 0;
  background: var(--ink-lift);
  background-image: var(--surface-plum);
  border: var(--hairline);
  border-radius: var(--r-ui);
  padding: var(--s7) var(--s7) var(--s7);
  transition: opacity var(--d-ui) var(--e-out), transform var(--d-reveal) var(--e-out);
}
.qf.is-open .qf__panel { opacity: 1; transform: translate(-50%, -50%); }

.qf__close {
  position: absolute;
  top: var(--s3); right: var(--s3);
  width: 40px; height: 40px;
  display: grid; place-items: center;
  color: var(--mute);
  border: var(--hairline);
  border-radius: var(--r-ui);
  background: transparent;
  transition: color var(--d-ui) var(--e-out), border-color var(--d-ui) var(--e-out);
}
.qf__close:hover { color: var(--paper); border-color: var(--purple); }
.qf__close svg { width: 14px; height: 14px; }

.qf__title { color: var(--paper); margin-top: var(--s3); }
.qf__sub   { color: var(--bone); margin-top: var(--s3); max-width: 42ch; }
.qf__sub a, .qf__notice a { color: var(--bone); text-decoration: underline; text-underline-offset: 3px; }
.qf__sub a:hover, .qf__notice a:hover { color: var(--purple); }

.qf__form { margin-top: var(--s6); display: flex; flex-direction: column; gap: var(--s4); }

/* Two fields per row on anything but a narrow phone. The panel scrolls on
   short viewports, so pairing the optional business name and phone against
   the fields they belong with keeps the submit button reachable without
   scrolling on a laptop. */
.qf__row {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--s3);
}
@media (max-width: 560px) {
  .qf__row { grid-template-columns: 1fr; }
}
.qf__submit { align-self: flex-start; margin-top: var(--s1); min-width: 170px; }
.qf__notice { color: var(--paper); border-top: var(--hairline); padding-top: var(--s4); }
.qf__alt { color: var(--mute); }
.qf__alt a { color: var(--bone); text-decoration: underline; text-underline-offset: 3px; }
.qf__alt a:hover { color: var(--purple); }
.qf__done { padding-block: var(--s5); }

@media (max-width: 560px) {
  .qf__panel { padding: var(--s7) var(--s5) var(--s6); width: calc(100vw - var(--s3) * 2); }
}
@media (prefers-reduced-motion: reduce) {
  .qf__scrim, .qf__panel { transition: none !important; }
  .qf__panel { transform: translate(-50%, -50%) !important; }
}


/* ==========================================================================
   12. Form primitives — shared
   These live here, not in contact.css, because the quick-form pop-up is shell
   chrome and appears on every page. Scoping them to the contact page's own
   stylesheet meant the pop-up's inputs rendered with no border or background
   anywhere else on the site.
   ========================================================================== */

.field { display: flex; flex-direction: column; gap: var(--s2); }

.field__label { color: var(--mute); }

.field__input {
  /* No explicit type sizing: input/select/textarea already inherit
     .t-body's exact values (size, weight, line-height, tracking) from the
     `font: inherit` base reset in tokens.css. This rule only sets colour,
     surface and spacing. */
  width: 100%;
  display: block;
  background: var(--ink-lift);
  border: var(--hairline);
  padding: 14px var(--s3);
  color: var(--paper);
  transition: border-color var(--d-ui) var(--e-out), background-color var(--d-ui) var(--e-out);
}
.field__input::placeholder { color: var(--mute); }
.field__input:hover { border-color: var(--purple-32); }

.field__textarea { resize: vertical; min-height: 168px; }

.field__select-wrap { position: relative; }
.field__select {
  appearance: none;
  -webkit-appearance: none;
  padding-right: var(--s6);
  cursor: pointer;
}
.field__select:invalid { color: var(--mute); }
.field__chevron {
  position: absolute;
  right: var(--s3);
  top: 50%;
  transform: translateY(-50%);
  width: 12px;
  height: 8px;
  color: var(--mute);
  pointer-events: none;
}

/* Invalid state — border colour only, no shadow, no red token. The visible
   message beneath is the real signal; colour is reinforcement, not the
   sole cue. */
.field.is-invalid .field__input { border-color: var(--paper); }
.field__error {
  display: none;
  color: var(--paper);
}
.field.is-invalid .field__error { display: block; }

/* The field boxes were reading as near-invisible against the plum panel:
   the border sat only a little off the surface and the fill was transparent.
   A lifted fill plus a stronger hairline gives each box a definite edge
   without resorting to a shadow (design-system.md §3 forbids them). */
.field__input {
  background: rgba(255, 255, 255, .045);
  border-color: rgba(255, 255, 255, .26);
}
.field__input:hover { border-color: var(--purple); }
.field__input:focus-visible {
  background: rgba(255, 255, 255, .07);
  border-color: var(--neon);
}


/* ==========================================================================
   13. Sticky scroller — shared
   Moved out of home.css: the [data-scroller] component in motion.js is now
   used by the homepage and the SOBO campaign page, and each page loads a
   different page-level stylesheet. Left in home.css, the campaign page got
   the markup with none of the layout — the sticky visual lost its grid
   column and stretched to full width with a small numeral adrift in it.
   ========================================================================== */

/* ==========================================================================
   STICKY SCROLLER — one sticky visual, stepped copy
   ========================================================================== */

.scroller__grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: var(--s10);
  align-items: start;
}
.scroller__visual { position: sticky; top: calc(var(--nav-h) + var(--s5)); }
.scroller__stack {
  position: relative;
  aspect-ratio: 4 / 5;
  border: var(--hairline);
  border-radius: var(--r-flat);
  overflow: hidden;
  background: var(--ink-lift);
}
.scroller__img {
  position: absolute;
  inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  opacity: 0;
  transform: scale(1.03);
  transition: opacity var(--d-reveal) var(--e-out), transform var(--d-reveal) var(--e-out);
}
.scroller__img.is-active { opacity: 1; transform: scale(1); }

.scroller__title { margin-top: var(--s3); margin-bottom: var(--s10); }
.scroller__step {
  padding-block: var(--s8);
  border-top: var(--hairline);
  opacity: .42;
  transition: opacity var(--d-ui) var(--e-out);
}
.scroller__step.is-active { opacity: 1; }
.scroller__num { color: var(--purple); }
.scroller__step h3 { color: var(--paper); margin-top: var(--s2); }
.scroller__step p  { color: var(--bone); margin-top: var(--s2); }

/* --- .scroller--band ------------------------------------------------------
   On the homepage this section sits immediately below the featured project,
   and the two were built the same way: a portrait image panel on the LEFT, a
   text column on the RIGHT, both on the canvas at the same heading size. Read
   in sequence they landed as one section repeated.

   Three things separate them, none of which touch the shared component:
   a raised band instead of the canvas, the visual flipped to the opposite
   side, and an asymmetric split so the balance differs too.
   Scoped to a modifier because /sobo uses .scroller as well. */
.scroller--band {
  background: var(--ink-lift);
  border-block: var(--hairline);
}
.scroller--band .scroller__grid {
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.85fr);
}
.scroller--band .scroller__visual { order: 2; }
.scroller--band .scroller__steps  { order: 1; }
/* The stack already carries --ink-lift; on a lifted band it would vanish. */
.scroller--band .scroller__stack { background: var(--ink); }

@media (max-width: 900px) {
  /* Stacked, the flip would put the image below the copy — keep reading order. */
  .scroller--band .scroller__visual { order: 0; }
  .scroller--band .scroller__steps  { order: 0; }
  .scroller--band .scroller__grid { grid-template-columns: 1fr; }
}

@media (max-width: 900px) {
  .scroller__grid { grid-template-columns: 1fr; gap: var(--s6); }
  /* Sticky is pointless stacked: the visual would cover the copy. */
  .scroller__visual { position: static; }
  .scroller__stack { aspect-ratio: 16 / 10; }
  .scroller__step { padding-block: var(--s6); }
}



/* ==========================================================================
   14. Cross-page primitives
   Both of these are used by more than one page but were defined in a single
   page's stylesheet, so the pages that did not load that sheet rendered them
   unstyled. Same root cause as the scroller above.
   ========================================================================== */

.page-note {
  margin-top: var(--s6);
  display: flex;
  align-items: center;
  gap: var(--s2);
  color: var(--mute);
}
.page-note::before {
  content: "";
  width: 6px;
  height: 6px;
  flex: none;
  background: var(--mint);
  border-radius: 50%;
}

.sec-head__link {
  flex: none;
  padding-bottom: var(--s1);
  color: var(--mute);
  border-bottom: 1px solid var(--hair);
  transition: color var(--d-ui) var(--e-out), border-color var(--d-ui) var(--e-out);
}
.sec-head__link:hover { color: var(--paper); border-color: var(--purple); }

/* ==========================================================================
   15. Service hover reveal  (bar.md M9, M10, M13, M14)
   Homepage .svc-row list only — deliberately NOT the /services .svc-item list.
   Kept in shell.css rather than home.css because .svc-thumb is a body-level
   element and the effect is a candidate to reuse elsewhere; a component that
   lives in one page's stylesheet and later moves is the defect this project
   keeps hitting.
   ========================================================================== */

/* --- M10: the highlighter -------------------------------------------------
   A wipe, not a fade — it is drawn left to right like a marker pulled across
   the row, which is the same gesture as the text highlighter reveal. Full
   bleed via 100vw so the bar runs past the text column to the window edge;
   body already carries overflow-x: clip, so this cannot start a scrollbar. */
.svc-row { position: relative; }

.svc-row::before {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  width: 100vw;
  margin-left: -50vw;
  background: var(--lime);
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform var(--d-min) var(--e-out);
  pointer-events: none;
  z-index: 0;
}
.svc-row.is-lit::before { transform: scaleX(1); }

/* Content has to clear the bar it is sitting on. */
.svc-row > * { position: relative; z-index: 1; }

/* M10: the text inverts to ink so it survives on the accent. Without this the
   row is pale-on-lime and unreadable. */
.svc-row.is-lit, .svc-row.is-lit * { color: var(--ink); }

/* M13: no second accent on a lit row. The arrow's own hover turns it lime,
   which would put lime on lime — while lit it goes ink like everything else. */
.svc-row.is-lit .svc-row__arrow {
  border-color: var(--ink);
  color: var(--ink);
}
/* The existing purple hover tint would muddy the accent bar underneath it. */
.svc-row.is-lit { background: transparent; }

/* --- M9: the thumbnail ----------------------------------------------------
   Fixed and outside the list entirely, so revealing one can never move a row.
   pointer-events: none keeps the row link clickable straight through it.
   5:6 is the reference's measured 0.83 portrait ratio. */
.svc-thumb {
  position: fixed;
  top: 0;
  left: 0;
  z-index: var(--z-thumb);      /* above content, below the nav */
  width: clamp(210px, 18.5vw, 310px);
  aspect-ratio: 5 / 6;
  pointer-events: none;
  overflow: hidden;
  border-radius: var(--r-flat);
  background: var(--ink-lift);
  will-change: transform, opacity;
}
.svc-thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }

@media (hover: none) and (pointer: coarse) {
  .svc-thumb { display: none; }
}

/* ==========================================================================
   16. Beni — corner assistant
   Purple + mint only, and deliberately no lime: he is on every viewport, so a
   lime detail would be a permanent fourth accent against M4's cap of three.
   No box-shadow anywhere (M5) — a hairline and a flat fill carry it, which is
   also what stops him looking like a bolted-on third-party chat widget.
   ========================================================================== */

.beni {
  position: fixed;
  right: var(--gutter);
  bottom: var(--gutter);
  z-index: var(--z-beni);          /* above content, BELOW the mobile menu (50) */
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: var(--s3);
  pointer-events: none;            /* only the parts that need it opt back in */
}
.beni > * { pointer-events: auto; }

/* --- the launcher --------------------------------------------------------- */
.beni__launch {
  width: 62px;
  height: 62px;
  display: grid;
  place-items: center;
  border: var(--hairline);
  border-radius: var(--r-ui);
  background: var(--plum);
  color: var(--paper);
  cursor: pointer;
  transition: border-color var(--d-ui) var(--e-out), transform var(--d-ui) var(--e-out);
}
.beni__launch:hover,
.beni__launch:focus-visible { border-color: var(--purple); transform: translateY(-3px); }

.beni__face  { width: 42px; height: 42px; overflow: visible; }
.beni__ear   { fill: var(--purple); }
.beni__body  { fill: var(--ink-lift); stroke: var(--purple); stroke-width: 1.6; }
.beni__lens  { fill: var(--plum); stroke: var(--purple); stroke-width: 1.4; }
.beni__pupil { fill: var(--mint); }
/* fill-box so GSAP's transformOrigin lands on the shape, not the whole SVG. */
.beni__ear, .beni__pupils, .beni__pupil { transform-box: fill-box; }
.beni__pupils { transform-box: view-box; }

/* --- the one nudge -------------------------------------------------------- */
.beni__bubble {
  position: relative;
  max-width: 17rem;
  padding: var(--s4) var(--s5) var(--s4) var(--s4);
  border: var(--hairline);
  border-radius: var(--r-ui);
  background: var(--plum);
  color: var(--bone);
  cursor: pointer;
  opacity: 0;
  transform: translateY(8px);
  transition: opacity var(--d-ui) var(--e-out), transform var(--d-ui) var(--e-out);
}
.beni.has-bubble .beni__bubble { opacity: 1; transform: translateY(0); }
.beni__bubble-text { margin: 0; }
.beni__bubble-close {
  position: absolute;
  top: 6px;
  right: 6px;
  width: 24px;
  height: 24px;
  display: grid;
  place-items: center;
  border: 0;
  background: none;
  color: var(--mute);
  cursor: pointer;
  transition: color var(--d-ui) var(--e-out);
}
.beni__bubble-close:hover { color: var(--paper); }
.beni__bubble-close svg { width: 10px; height: 10px; }

/* --- the panel ------------------------------------------------------------ */
.beni__panel {
  position: relative;
  width: min(21rem, calc(100vw - var(--gutter) * 2));
  /* The panel grows upward from the corner, so on a short laptop it ran off the
     TOP of the window (measured 127px clipped at 1280x660). Capped to what is
     actually available, and it scrolls inside itself past that. */
  max-height: calc(100vh - var(--gutter) * 2 - 76px);
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: var(--s5);
  border: var(--hairline);
  border-radius: var(--r-ui);
  background: var(--plum);
  opacity: 0;
  transform: translateY(10px);
  transition: opacity var(--d-ui) var(--e-out), transform var(--d-ui) var(--e-out);
}
.beni.is-open .beni__panel { opacity: 1; transform: translateY(0); }

.beni__close {
  position: absolute;
  top: var(--s3);
  right: var(--s3);
  width: 28px;
  height: 28px;
  display: grid;
  place-items: center;
  border: var(--hairline);
  border-radius: var(--r-ui);
  background: none;
  color: var(--mute);
  cursor: pointer;
  transition: color var(--d-ui) var(--e-out), border-color var(--d-ui) var(--e-out);
}
.beni__close:hover { color: var(--paper); border-color: var(--purple); }
.beni__close svg { width: 11px; height: 11px; }

.beni__title { color: var(--paper); margin-top: var(--s2); }
.beni__sub   { color: var(--bone); margin-top: var(--s2); }
.beni__form  { margin-top: var(--s4); display: flex; flex-direction: column; gap: var(--s3); }
.beni__send  { align-self: flex-start; }
.beni__notice { color: var(--paper); border-top: var(--hairline); padding-top: var(--s3); }

@media (max-width: 520px) {
  .beni__launch { width: 54px; height: 54px; }
  .beni__face { width: 34px; height: 34px; }
  .beni__bubble { max-width: 14rem; }
}

/* Reduced motion: he still works, he just does not move. */
@media (prefers-reduced-motion: reduce) {
  .beni__launch,
  .beni__bubble,
  .beni__panel { transition: none; }
  .beni__launch:hover { transform: none; }
}

/* ==========================================================================
   17. Unreplaced [PLACEHOLDER] copy
   Deliberately loud. A founder biography or campaign date that never got
   filled in should be impossible to miss, not quietly wrong on a live page.
   Used by /sobo and /about.
   ========================================================================== */
.is-placeholder {
  color: var(--ink);
  background: var(--lime);
  padding: 0 .4em;
  border-radius: var(--r-ui);
}

/* ==========================================================================
   18. Scroll progress
   Started life as .sobo-progress on one page. Renamed and moved here now that
   it runs site-wide: a component used by every page has no business living in
   one page's stylesheet, which is the defect this project keeps repeating.
   ========================================================================== */
.scroll-progress {
  position: fixed;
  top: 0; left: 0; right: 0;
  height: 2px;
  z-index: calc(var(--z-nav) + 1);
  background: var(--purple-16);
  pointer-events: none;
}
.scroll-progress i {
  display: block;
  height: 100%;
  width: 100%;
  transform: scaleX(0);
  transform-origin: 0 50%;
  background: var(--lime);
  will-change: transform;
}
/* No scroll-linked motion under reduced motion: the bar would still be honest,
   but it is a continuous animation and the setting says no. */
@media (prefers-reduced-motion: reduce) { .scroll-progress { display: none; } }

/* ==========================================================================
   19. Accent word
   Lifts a single word out of a headline. --lime is THE accent (design-system
   §1) and this is exactly what it is for: the one thing the eye lands on.
   Survives the line/word splitters, which carry markup onto their clones.

   Use it sparingly — M4 caps the accent at three elements per viewport, and
   most sections already spend one on the nav CTA and one on the scroll bar.
   ========================================================================== */
.t-accent { color: var(--lime); }

/* ==========================================================================
   20. Particle field
   Was .hero__particles in home.css. The splash at / uses the same canvas and
   does not load home.css, so the rule never applied there: the canvas dropped
   into normal flow at 18,035px tall and took the page height with it. Renamed
   because it is no longer hero-only.

   z-index 0 puts it behind the page's own gradient work; DOM order (it is
   prepended) decides the tie. pointer-events: none is what keeps the CTAs and
   nav clickable straight through it.
   ========================================================================== */
.particle-field {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  display: block;
}
