/* ============================================================
   BASE — tokens, reset, typography, grain, cursor, loader, nav
   Rodion Ostrovski — portfolio
   ============================================================ */

/* ---------- FONTS ----------
   TWO FACES CARRY THE WHOLE PORTFOLIO.

   Plus Jakarta Sans is the interface: every heading, every paragraph,
   every label and the navigation. One variable file, 200 to 800, so the
   distance between a 13px tracked label and an 800-weight display line
   is a change of WEIGHT rather than a change of family. That is the
   hierarchy this site was already asking for and was faking with three
   families.

   Instrument Serif Italic is the counter-voice, and it is spent on
   phrases rather than on words: the one part of a sentence the page
   wants read differently.

   Two product faces remain, and they are quarantined. Inter belongs to
   Race Intel and Figtree to Doit; both appear ONLY inside those case
   studies' brand-system specimens, where the point is to show the
   product's own type. Neither is ever used for this site's own voice. */
@font-face {
  font-family: 'Plus Jakarta Sans';
  font-style: normal;
  font-weight: 200 800;
  font-display: swap;
  src: url('../assets/fonts/jakarta-var.woff2') format('woff2-variations'),
       url('../assets/fonts/jakarta-var.woff2') format('woff2');
}
@font-face {
  font-family: 'Instrument Serif';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('../assets/fonts/instrument-serif-400.woff2') format('woff2');
}
@font-face {
  font-family: 'Instrument Serif';
  font-style: italic;
  font-weight: 400;
  font-display: swap;
  src: url('../assets/fonts/instrument-serif-400-italic.woff2') format('woff2');
}

/* ---------- TOKENS ---------- */
:root {
  --void: #070706;
  --void-2: #0d0d0b;
  --void-3: #131311;
  --bone: #ece8e1;
  /* Two readable tiers, both measured against --void:
     --bone-dim  7.7:1  body copy
     --bone-soft 6.2:1  small labels, captions, meta
     --bone-mute is NOT readable (1.7:1) and is only for de-emphasis of
     large type and for decoration — never for text that must be read.

     Both tiers were raised (0.58 -> 0.68, 0.52 -> 0.60). Light type on a
     near-black ground reads thinner than the same value on paper, and a
     case study is a long read rather than a caption: the old pair cleared
     AA and still asked the reader to work. The light spreads inside a case
     page already declare their ink at exactly these two alphas, so the two
     grounds are now symmetric instead of the paper one being kinder. */
  --bone-dim: rgba(236, 232, 225, 0.68);
  --bone-soft: rgba(236, 232, 225, 0.60);
  --bone-mute: rgba(236, 232, 225, 0.22);
  --bone-ghost: rgba(236, 232, 225, 0.08);
  --line: rgba(236, 232, 225, 0.12);
  --line-soft: rgba(236, 232, 225, 0.07);
  /* The second ink. Vermilion never fills shapes and never sits behind
     text — it colours type the author speaks in: the hero claim word, the
     section numerals, the footer signature, selection, one hover. 5.06:1
     on --void (measured), so it passes AA even at kicker sizes. On
     vermilion, only --void text is legal (5.06:1); --bone is 3.26:1 and
     must never carry small text on it. */
  --signal: #E24A3B;

  /* ---------- THE FIVE VOICES ----------
     Roles, not sizes. Every text rule on the site resolves to one of these,
     so retuning the page is one edit here instead of a hunt through a
     hundred one-off font-sizes.

     There are five, and they are named after the JOB rather than the size,
     because the size is fluid and the job is not:

       STATEMENT  the argument itself, set big enough to be read from
                  across the room. Components own their own clamps; the
                  token is the floor they share.
       TITLE      the name of a section. Furniture, not argument.
       LEAD       the sentence that opens a section, and the sentence that
                  closes one. The conclusions. Bigger than the paragraphs
                  around it because it is the part a skimming reader is
                  meant to catch.
       BODY       running prose. The read.
       META       labels, numerals, captions, rails. Everything that names
                  a thing rather than saying one.

     WHAT MOVED, AND WHY. The reading roles went up and the label roles did
     not. A case study is fifteen thousand words of argument, and it was
     being set at 17px body and 15px support — sizes tuned for a caption
     under a picture, not for a page somebody is asked to read to the end.
     BODY is now 20px on a laptop and SUPPORT 17px, which is where an
     editorial long-read sits. LEAD is new: without it the page fell from a
     34px section statement straight to 17px prose, a two-to-one drop with
     nothing in between, and every conclusion landed in the same voice as
     the sentences it was concluding.

     The label roles held station on purpose. The distance between the
     smallest type and the largest IS the hierarchy; raising the floor with
     the ceiling would have flattened everything the last pass gained. */
  --t-brand:     1.0625rem;                          /* the RO mark */
  --t-nav:       0.8125rem;                          /* nav links, email action, footer links */
  --t-label:     0.8125rem;                          /* META: kickers, rails, captions, numbers */
  --t-label-s:   0.75rem;                            /* META, tightest: tile counts, dense rails */
  --t-head:      clamp(0.875rem, 1.05vw, 1.0625rem); /* section eyebrows */
  --t-note:      0.9375rem;                          /* hero evidence — the home introduction */
  --t-body-s:    clamp(1rem, 0.945rem + 0.24vw, 1.0625rem);  /* BODY, supporting: 16 -> 17px */
  --t-body:      clamp(1.0625rem, 0.95rem + 0.42vw, 1.25rem); /* BODY: 17 -> 20px */
  --t-lead:      clamp(1.25rem, 1.05rem + 0.95vw, 2rem);     /* LEAD: 20 -> 32px */
  /* The floor is 1.5rem, not 1.2. On a phone the LEAD role bottoms out at
     20px and the title was landing at 19.2 — an introduction the same size
     as the heading it introduces. 24px of caps against 20px of sentence
     case is a step you can see. */
  --t-title:     clamp(1.5rem, 1.85vw, 1.6rem);      /* TITLE: section names, in caps */
  --t-statement: clamp(1.6rem, 4.4vw, 3.75rem);      /* STATEMENT: the argument */
  /* The chapter numeral. 48px on a phone, 108px on a laptop, 120px capped.
     It is not a sixth voice: it carries no words, it is one grey figure
     marking where you are in a sequence. */
  --t-chapter:   clamp(3rem, 7.5vw, 7.5rem);

  /* ONE SANS, TWO VOICES. `--sans` is the only sans-serif the portfolio
     owns. `--display` and `--mono` survive as names because the site
     genuinely has two voices — the big statement and the small label —
     and roughly two hundred rules already resolve one or the other. They
     now point at the same family, which is the change: the two voices
     are separated by weight, size and tracking, not by a second and
     third typeface. `--mono` is a legacy name for the LABEL voice; there
     is no monospaced type on this site and there never was. */
  --sans: 'Plus Jakarta Sans', 'Helvetica Neue', Arial, sans-serif;
  --serif: 'Instrument Serif', 'Didot', 'Bodoni 72', Georgia, serif;
  --display: var(--sans);
  --mono: var(--sans);

  --gut: clamp(1.25rem, 4vw, 4.5rem);
  --rule: clamp(4rem, 12vh, 9rem);

  --ease: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-io: cubic-bezier(0.65, 0, 0.35, 1);

  --z-grain: 9600;
  --z-cursor: 9800;
  --z-veil: 9900;
}

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

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: auto;
}

body {
  margin: 0;
  background: var(--void);
  color: var(--bone);
  font-family: var(--mono);
  font-size: 16px;
  line-height: 1.6;
  overflow-x: hidden;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}

/* Lenis owns the scroll; the wrapper must not clip pinned sections. */
html.lenis, html.lenis body { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
.lenis.lenis-stopped { overflow: hidden; }

h1, h2, h3, h4, p, figure, blockquote, ul, ol {
  margin: 0;
  padding: 0;
}
ul, ol { list-style: none; }

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

a {
  color: inherit;
  text-decoration: none;
}

button {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
}

::selection {
  /* The reader's own act, marked in the second ink. --void on --signal
     is 5.06:1 — the only text colour that passes on this ground. */
  background: var(--signal);
  color: var(--void);
}

/* Hide the native scrollbar without losing scrollability */
body::-webkit-scrollbar { width: 0; height: 0; }
body { scrollbar-width: none; }

/* ---------- TYPE PRIMITIVES ---------- */
.kicker {
  font-family: var(--mono);
  font-weight: 500;
  font-size: var(--t-label);
  /* 0.22em at 13px tracks wider in absolute terms than it did at 11px, so
     the labels give back a little to keep the same optical rhythm. */
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--bone-dim);
}

/* The numeral of a section head, set in the second ink. Four of these
   thread the page — 01 through 04 — and they are the only red the
   layout carries between the hero claim and the footer signature.
   5.06:1 on --void, so the small size is still AA. */
.rubric { color: var(--signal); }

.display {
  font-family: var(--display);
  font-weight: 800;
  line-height: 0.85;
  letter-spacing: -0.045em;
  text-transform: uppercase;
}

/* ---------- HOW LINES BREAK ----------
   Two rules, applied by role, because the two roles have opposite problems.

   A heading's problem is a short last line: three big words and then one,
   which reads as a mistake in the composition. `balance` asks the browser
   to even the lines out instead. It gives up past a few lines, which is
   exactly the range headings live in.

   A paragraph's problem is the single word left alone at the end. `pretty`
   keeps the last line populated and avoids breaking the final word off on
   its own. It costs nothing where it is unsupported.

   Neither of these can save a line the copy itself breaks — punctuation
   that belongs to a phrase has to live inside that phrase's element or it
   becomes a word of its own. See `.pull` in css/case.css. */
h1, h2, h3, h4,
.display, .case-title, .section-title, .pull, .case-lede, .head-major {
  text-wrap: balance;
}
p, li, dd, blockquote, figcaption {
  text-wrap: pretty;
}

.display em,
.serif {
  font-family: var(--serif);
  font-style: italic;
  font-weight: 400;
  letter-spacing: -0.015em;
  text-transform: none;
}

/* Visually gone, still announced. The hero's name is painted on a canvas,
   which is decorative by definition — this is where the real <h1> lives. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* Masked line reveals: the inner span slides up out of a clipped parent. */
.mask {
  display: block;
  overflow: hidden;
  /* a hair of padding so descenders and italic overshoot aren't sheared */
  padding-bottom: 0.08em;
  margin-bottom: -0.08em;
}
/* No `will-change` here. These animate ONCE, on entrance, and there are a
   dozen of them plus 24 `.ch` spans in the footer — promoting all of them
   for the life of the page cost 50 composited layers and 4.25 viewports of
   GPU memory on a phone, permanently, for motion that is over in a second.
   GSAP promotes what it is animating while it animates it. */
.mask > * { display: block; }

/* ---------- FILM GRAIN ---------- */
#grain {
  position: fixed;
  inset: -120px;
  z-index: var(--z-grain);
  pointer-events: none;
  opacity: 0.055;
  mix-blend-mode: overlay;
  background-repeat: repeat;
  background-size: 180px 180px;
  animation: grain-shift 0.9s steps(1) infinite;
  contain: strict;
}

@keyframes grain-shift {
  0%   { transform: translate3d(0, 0, 0); }
  12%  { transform: translate3d(-6%, -4%, 0); }
  25%  { transform: translate3d(4%, -7%, 0); }
  37%  { transform: translate3d(-8%, 5%, 0); }
  50%  { transform: translate3d(7%, 3%, 0); }
  62%  { transform: translate3d(-3%, 8%, 0); }
  75%  { transform: translate3d(5%, -5%, 0); }
  87%  { transform: translate3d(-7%, -2%, 0); }
  100% { transform: translate3d(0, 0, 0); }
}

/* THE GRAIN IS THE MOST EXPENSIVE THING ON THIS PAGE ON A PHONE.
   It is a `position: fixed` layer of 2.1 viewports (the -120px bleed exists
   only to give the drift somewhere to go), it composites with
   `mix-blend-mode: overlay`, and it was the page's ONLY running CSS
   animation — stepping its transform eight times a second, forever. Blending
   two viewports' worth of layer against content that is itself moving is
   work the compositor cannot cache, and it lands on exactly the frames the
   user is scrolling.

   On a touch device it holds still and stops bleeding: 2.1 viewports of
   re-blended layer become 1, and the eight steps a second become none. The
   texture stays, which is all it was ever contributing at 5% opacity on a
   phone screen. Desktop is untouched. */
@media (hover: none), (pointer: coarse) {
  #grain {
    inset: 0;
    animation: none;
  }
}

/* A second, slower vignette layer — keeps edges from feeling flat */
#vignette {
  position: fixed;
  inset: 0;
  z-index: calc(var(--z-grain) - 1);
  pointer-events: none;
  background: radial-gradient(120% 90% at 50% 42%, transparent 38%, rgba(0, 0, 0, 0.55) 100%);
}

/* ---------- CUSTOM CURSOR ----------
   A DOT, AND ONE LABELLED DISC. Nothing in between.

   WHAT THIS REPLACED. There were three things on screen at once: a 5px dot,
   a 36px outlined ring that followed it at a lag, and — over a project
   preview — a white disc with a dark pill of text floating on top of the
   whole arrangement. The ring was the weakest of the three: an empty
   outline chasing a dot says nothing a dot does not already say, and at 54
   or 94px it was a second object competing with the first.

   So the resting cursor is the dot alone, and the disc is drawn ONLY where
   it has something to say — over a project preview, where it names what the
   click gets you. The dot is hidden while the disc is up, because the disc
   has its own centre and two centres 94px apart is two cursors.

   BOTH OF THEM BLEND, AND THE LABEL IS THE HOLE IN THE DISC.
   `mix-blend-mode: difference` is what keeps a mark visible over a black
   page, a white poster and everything between: it is negative space rather
   than ink, so it cannot be the wrong colour for what it is over.

   The disc was opaque for one release, because a blend applies to the whole
   group and white text inside an inverted disc comes out white on white.
   That is true, and it is also the mechanism: inside a `difference` group,
   WHITE inverts what is behind it and BLACK leaves it exactly as it was. So
   the disc is white and the words are black — the circle inverts the
   artwork under it and the two lines of type punch back through to the
   original picture. No plate, no outline, no second layer, and no colour
   the label can be illegible against, because the label is not a colour: it
   is the page showing through.

   THE DISC IS ALSO WHY THE DOT GREW. At 7px a blended dot is a speck; the
   disc is 94px, and the step between the resting state and the labelled one
   was the whole width of the screen. 14px is still a dot and no longer a
   speck, and 18px answers an ordinary link without becoming a shape. */
#cursor-ring,
#cursor-dot {
  position: fixed;
  top: 0;
  left: 0;
  z-index: var(--z-cursor);
  pointer-events: none;
  border-radius: 999px;
  opacity: 0;
  will-change: transform;
}

/* The dot is the cursor. 14px, blended, and it grows four pixels over an
   ordinary link — enough to answer the pointer without becoming a shape. */
#cursor-dot {
  width: 14px;
  height: 14px;
  background: #fff;
  mix-blend-mode: difference;
  transition: width 0.3s var(--ease), height 0.3s var(--ease);
}
body.cursor-link #cursor-dot { width: 18px; height: 18px; }

/* The size states animate width/height, never `scale`. Scaling a disc that
   has text in it stretches the raster the text was drawn into, which is
   what made the old badge look like a low-res JPEG on a high-density
   display. Animating the box keeps the disc and its label rendered at
   their true size at every frame. Centring comes from xPercent/yPercent, so
   size changes stay centred without touching margins.

   AT REST IT IS NOTHING. Zero across, no ground, no border: `js/core.js`
   fades the pair in together on the first pointer move, so the disc has to
   be invisible by geometry rather than by opacity or the fade would print
   a 36px circle over the page. */
#cursor-ring {
  width: 0;
  height: 0;
  display: grid;
  place-items: center;
  background: transparent;
  mix-blend-mode: difference;
  transition: background-color 0.3s var(--ease);
}

/* THE MARK IS INSIDE THE DISC, AND IT IS THE HOLE IN IT.
   Pure #000, because inside a `difference` group black is the identity: the
   stroke subtracts nothing, so what shows through it is the artwork the
   circle is standing on, unaltered. Every other value would tint it.

   ONE ARROW, NOT TWO WORDS. The disc used to set "Case study" or "View
   project" on two lines. Both were captions inside a pointer — the reader
   is already on the tile and has already read its name — and having two of
   them meant the mark changed shape as the pointer crossed from a case
   study to a graphic piece, which is a lot of movement to say the same
   thing twice. A diagonal arrow says "this leads out of the page" without
   being read, and it is the same mark over every preview on the site.

   34px IN A 94px DISC. About a third across, which leaves the arrow in its
   own air rather than reaching for the curve: the diagonal's ends land
   about 24px from the centre against a 47px radius. The 2.1px stroke is
   measured rather than chosen — at 1.6 the shape thinned out over pale
   artwork, where the inverted disc is dark and the cut-out stroke is the
   pale original showing through. */
#cursor-ring .ct-in {
  display: block;
  width: 34px;
  height: 34px;
  color: #000;
  opacity: 0;
  transition: opacity 0.2s var(--ease);
}
#cursor-ring .ct-in svg {
  display: block;
  width: 100%;
  height: 100%;
}

/* THE ONE STATE THE DISC EXISTS FOR. Pure #FFF, and it has to be: inside a
   `difference` group white is the full inversion and anything below it is a
   partial one. #F2EFE9 — the page's own light, which this was while the
   disc was opaque — would have left a faint warm cast over the inverted
   artwork and, worse, a near-white disc over near-white artwork inverts to
   near-black while the black mark stays put, which is the one combination
   that cannot happen at full strength.

   The pair is therefore always maximally separated: the circle is the exact
   inverse of whatever it covers, and the arrow is exactly what it covers.
   Dark artwork, pale artwork, a red poster or a halftone — the contrast
   between the two is the contrast the artwork already has, which is the
   only guarantee that survives every image on the site. */
body.cursor-view #cursor-ring { background: #fff; }
body.cursor-view #cursor-ring .ct-in { opacity: 1; }
/* Two centres 94px apart is two cursors, and one of them is now 14px. */
body.cursor-view #cursor-dot { opacity: 0 !important; }

html.has-cursor,
html.has-cursor * { cursor: none !important; }

/* ---------- WHERE THE RING STANDS DOWN ----------
   `data-cursor="none"` hides the custom pointer and gives the platform one
   back. It is for controls that are small, outlined and self-explanatory:
   a 60px ring centred on a 120px button covers the label it is pointing
   at, and the ring's whole argument is that it reveals what a target does.

   The selector has to out-rank `html.has-cursor *` above, which is why it
   repeats the `html.has-cursor` scope: an attribute selector plus a class
   beats a class plus the universal selector, and both carry !important. */
/* `!important` because the ring's opacity is an INLINE style: js/core.js
   fades the pair in with `gsap.to([ring, dot], { opacity: 1 })` the first
   time the pointer moves, and an inline value beats any selector. The file
   already reaches for it one rule down, for the same reason. */
body.cursor-off #cursor-ring,
body.cursor-off #cursor-dot { opacity: 0 !important; }
html.has-cursor [data-cursor='none'],
html.has-cursor [data-cursor='none'] * { cursor: pointer !important; }

/* ---------- THE CHEVRON ----------
   ONE DIRECTIONAL GLYPH FOR THE WHOLE SITE, DRAWN RATHER THAN TYPED.

   The site used to point in three different ways at once: the character
   `\u2192` in the home page's two label rows and on the 404, a 44x16 line-and-head
   arrow at the foot of every case page, and a third 14x10 version in the
   header's Back control. Three drawings of one idea.

   This is the only one now: two thick arms meeting at a point, with no
   horizontal shaft. The geometry is two line segments — `M9 5.6 15.6 12
   9 18.4` on a 24-square — and the softened corners are `stroke-linecap`
   and `stroke-linejoin: round`, so there is no path data to maintain and
   the joint is a real radius at every size.

   `stroke: currentColor` and `width: 1em` are what make it reusable: a
   chevron takes the colour and the size of the control it sits in, so a
   13px label row and a 40px section link get the same glyph at their own
   scale without a second file or a second rule.

   `.is-left` mirrors it for Back, All work and previous-slide controls;
   `.is-up` and `.is-down` are the same glyph rotated. Every one of them is
   decorative — the control's own text or `aria-label` carries the name —
   so the markup always sets `aria-hidden="true"` and `focusable="false"`. */
.chev {
  display: block;
  flex: 0 0 auto;
  width: 1em;
  height: 1em;
}
/* 3.6 on a 24-square is 15% of the box — thick, which is the point: the
   reference is two heavy arms, not a hairline. It is one number for every
   size, so a 13px control and a 29px one carry the same weight of glyph in
   proportion rather than the same number of pixels. */
.chev path {
  fill: none;
  stroke: currentColor;
  stroke-width: 3.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.chev.is-left  { transform: scaleX(-1); }
.chev.is-up    { transform: rotate(-90deg); }
.chev.is-down  { transform: rotate(90deg); }

/* ---------- FOCUS ----------
   The custom cursor hides the pointer, so a keyboard user has nothing
   else to go on. :focus-visible only fires for keyboard/AT navigation,
   so this never appears on a mouse click.

   `currentColor` is what makes it work everywhere: on the dark page the
   text is bone so the ring is bone; on an inverted work row the text is
   void so the ring flips dark. One rule, legible on both. */
:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 4px;
  border-radius: 2px;
}

/* Tight to the shape where the element already has one */
#cursor-ring:focus-visible,
.lb-nav button:focus-visible,
.lb-close:focus-visible {
  outline-offset: 3px;
  border-radius: 999px;
}

/* The case-study tiles are inside the gutters now, not full-bleed, so the
   default outset ring fits. It only needs room to clear the stage border. */
.wk-item:focus-visible {
  outline-offset: 6px;
}

.gd-item:focus-visible {
  outline-offset: 6px;
}

/* Skip link — the first thing a keyboard user reaches. */
.skip-link {
  position: fixed;
  top: 0;
  left: 50%;
  transform: translate(-50%, -120%);
  z-index: 10000;
  padding: 0.85rem 1.5rem;
  background: var(--bone);
  color: var(--void);
  font-family: var(--mono);
  font-weight: 500;
  font-size: 0.75rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  transition: transform 0.3s var(--ease);
}
.skip-link:focus {
  transform: translate(-50%, 0);
  outline: none;
}

/* ---------- SCROLL PROGRESS ----------
   A single hairline across the top edge. Difference blending keeps it
   readable over the inverted work rows as well as the void. */
#progress {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 1px;
  z-index: 700;
  pointer-events: none;
  background: rgba(236, 232, 225, 0.09);
  mix-blend-mode: difference;
}
#progress i {
  display: block;
  height: 100%;
  width: 100%;
  background: #fff;
  transform: scaleX(0);
  transform-origin: 0 50%;
  will-change: transform;
}

/* ---------- VEIL (page transitions) ----------
   The fallback ground. js/curtain.js draws the real transition on a canvas
   above this; the veil is what a browser with no 2D context, or a reader who
   has asked for no motion, gets instead. See js/core.js. */
/* ---------- THE VEIL — THE FIRST FRAME IS ALREADY COVERED ----------
   WHAT WAS WRONG. Every script on this site is at the foot of <body>, so the
   browser can paint the whole styled page before `curtain.js` has run, let
   alone before `core.js` calls `Curtain.open()`. The destination of a
   transition therefore appeared for a beat, then a closed curtain dropped
   over it, then the curtain lifted. The transition was correct; what came
   before it was not.

   THE VEIL IS THE FIX, AND IT COSTS NOTHING. It is one element declared
   before any content, painted by a stylesheet the <head> already blocks on,
   so the very first frame the browser can produce is opaque. `curtain.js`
   clears it in the same synchronous step that draws the closed canvas over
   it, so the handover happens under cover and is never a frame the reader
   sees.

   IT LOOKS LIKE THE CLOTH, NOT LIKE A BLACK RECTANGLE. If it were flat
   `--void` the sequence would be black, then grey pleats, then the lift —
   a pop rather than a flash, but still a pop, and on a cold cache the black
   can hold for a hundred milliseconds. The two gradients below are the same
   cloth as `js/curtain.js` and `js/pleat.js` draw: FOLD greys around a
   42px pleat, under the same head-and-hem ramp the canvas uses at full
   cover (0.86 / 0.30 / 0.30 / 0.90 of --void). It is an approximation —
   the canvas's fold widths are irregular and viewport-derived, these are
   even — and it does not have to be more than that, because the canvas is
   opaque and covers it completely the moment it draws. THIS IS THE THIRD
   PLACE THE CLOTH IS DESCRIBED; the other two are the two JS files, and
   restyling it means editing all three.

   IT MUST NEVER STRAND A READER. `pointer-events: none` means even a veil
   that somehow survived cannot block a link; `curtain.js` clears it on a
   3s timer whatever else happens; and the <noscript> rule on each page
   removes it outright when no script will ever run. */
#veil {
  position: fixed;
  inset: 0;
  z-index: calc(var(--z-veil) - 1);
  background:
    linear-gradient(180deg,
      rgba(7, 7, 6, 0.86) 0%,
      rgba(7, 7, 6, 0.30) 26%,
      rgba(7, 7, 6, 0.30) 74%,
      rgba(7, 7, 6, 0.90) 100%),
    repeating-linear-gradient(90deg,
      #383836 0px,
      #5e5e5a 14px,
      #6d6d68 26px,
      #4a4a47 38px,
      #383836 42px);
  pointer-events: none;
  opacity: 1;
  visibility: visible;
}

/* ---------- CURTAIN (page transitions) ----------
   A full-viewport canvas carrying the same pleated cloth as the hero. It is
   `display: none` between transitions: it covers the whole window, and a
   layer that size must cost nothing on a page nobody is leaving. JS owns the
   backing store; this rule owns the display size, the same split pleat.js
   uses — a stale buffer then looks soft for a frame instead of opening a
   visible strip of uncovered page down one edge. */
#curtain {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: var(--z-veil);
  pointer-events: none;
  display: none;
}

/* ---------- BOOT PROGRESS ----------
   All that is left of the loader. There is no loading screen: the hero's
   curtain paints on the first frame already closed, so the page has its
   subject immediately and a full-screen counter would only have been hiding
   it. The old one was theatre besides — it waited on three images, reached
   100% almost at once, and then played 2.2s of exit choreography.

   This is the honest remainder: one hairline at the bottom edge, driven by
   real image loads, removed the moment it finishes. Nothing is locked while
   it runs — you can scroll straight past it. */
#boot {
  position: fixed;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 1px;
  z-index: 700;
  pointer-events: none;
  background: var(--bone);
  transform: scaleX(0);
  transform-origin: 0 50%;
  will-change: transform;
}

/* ---------- NAV ---------- */
#nav {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  z-index: 500;
  display: flex;
  align-items: center;
  /* THE BRAND IS NOT IN HERE ANY MORE (see the blend note below), so the
     bar holds one group and pushes it to the right edge. The left gutter
     is padded past the wordmark's own 44px box so a long set of links can
     never run under it. */
  justify-content: flex-end;
  gap: 1rem;
  padding: var(--nav-pad) var(--gut);
  padding-left: calc(var(--gut) + 44px + 1.5rem);
  will-change: transform;
}
/* Kept as one token so the bar's height and the anchor offset below can
   never drift apart. */

/* ---------- THE HOME PAGE'S BAR ----------
   The nav used to hide on the way down and come back on the way up, and it
   was `mix-blend-mode: difference` with no ground of its own — which works
   over a black page and fails over the two things this page is now full of:
   a #CACACA product render and a masonry of bright covers. Difference
   inverts against whatever is under it, so the labels changed colour tile
   by tile as the grid scrolled past.

   So on the home page it stops being a floating set of words and becomes a
   bar: always there, its own dark ground, one ink. The blend goes with it —
   over an opaque ground there is nothing left to blend against, and keeping
   it would invert the bar itself.

   THE FALLBACK IS THE DEFAULT, NOT THE EXCEPTION. `background` is declared
   at 0.92 first and only dropped to 0.82 inside `@supports`, so a browser
   without backdrop-filter gets a solid-enough bar rather than a translucent
   one with nothing behind it.

   Sampled off the rendered page with the graphic grid scrolled under it —
   the brightest thing this bar ever crosses — the strip reads 16 of 255 and
   the bone labels measure 15.6:1 on it. The no-backdrop path is darker
   still, because 0.92 of --void admits less of what is behind it.

   It is a hairline and a wash, not a capsule: no radius, no shadow, no
   inset margin. The page already has one floating control language on the
   case pages and this is not it. */
body.is-home #nav {
  mix-blend-mode: normal;
  background: rgba(7, 7, 6, 0.92);
  border-bottom: 1px solid var(--line-soft);
  color: var(--bone);
}
@supports ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  body.is-home #nav {
    background: rgba(7, 7, 6, 0.82);
    -webkit-backdrop-filter: blur(18px) saturate(1.15);
    backdrop-filter: blur(18px) saturate(1.15);
  }
}

/* ---------- THE BRAND INVERTS; THE BAR DOES NOT ----------
   `mix-blend-mode: difference` is what lets two letters survive a page that
   alternates between near-black, paper and photographs: the mark is not a
   colour, it is the opposite of whatever is behind it.

   IT CANNOT LIVE INSIDE THE HEADER, AND THIS IS THE BUG THAT WAS FIXED.
   A blend reaches only as far as the nearest ancestor that creates a
   stacking context, and `#nav` is fixed with a z-index, so it creates one.
   While RO was a child of the header it was differencing against the
   header's own empty box — against nothing — and simply painting itself in
   `--bone`. On a case page's paper spread that is #ECE8E1 on #F4F4F3:
   measured 1.06 : 1, which is invisible.

   So the mark is a fixed element of its own, a sibling of the bar, painted
   one step above it and blending against the page at the root. The header
   is no longer blended at all: it cannot be, because `.nav-back` is a
   vermilion outline and a vermilion outline differenced against paper comes
   out teal.

   The geometry is written twice on purpose — the padding here and the
   header's padding both read `--nav-pad`, so the mark and the links cannot
   drift out of line. */
#nav { mix-blend-mode: normal; }

.nav-brand {
  position: fixed;
  top: var(--nav-pad);
  left: var(--gut);
  /* One over the bar's 500. */
  z-index: 501;
  mix-blend-mode: difference;
}

/* ---------- ANCHOR TARGETS CLEAR THE BAR ----------
   A fixed header will happily park a section heading underneath itself.
   `scroll-margin-top` is the one property that fixes it for every route
   into a section at once — a nav link, a #hash arriving from a case page,
   the skip link — because it moves the scroll TARGET rather than the
   element, so nothing in the layout shifts.

   THE VALUE IS DERIVED, NOT GUESSED. The bar is one 44px control between
   two paddings, and `--nav-pad` is the same token the padding above uses,
   so the two cannot drift apart. The 1.5rem on top is the air between the
   bar and the heading it just cleared.

   TWENTY PIXELS SHORTER ON A DESKTOP. The upper bound was 1.75rem, which
   put the bar at 44 + 56 + 1 = 101px — a tenth of a 900px laptop viewport
   spent on four links. At 1.125rem it is 44 + 36 + 1 = 81px: the control
   inside it is still the full 44px touch target, so nothing about the hit
   area moves, and the anchor offset below is written from the same token,
   so every heading still lands clear of the bar.

   THE PHONE IS UNTOUCHED. The lower bound stays 1.1rem, which is what the
   clamp already resolved to below about 750px — the bar was 80px there
   before this change and it is 80px after it. */
:root { --nav-pad: clamp(1.1rem, 2.4vw, 1.125rem); }

body.is-home :where(#work, #graphic, #expertise, #about, #contact, #top) {
  scroll-margin-top: calc(44px + 2 * var(--nav-pad) + 1.5rem);
}

.nav-brand {
  font-family: var(--display);
  font-weight: 800;
  font-size: var(--t-brand);
  letter-spacing: 0.02em;
  text-transform: uppercase;
  display: inline-flex;
  align-items: center;
  /* "RO" is only two glyphs, so height alone left the target 32px wide.
     min-width rather than side padding, so the mark stays on the gutter. */
  min-height: 44px;
  min-width: 44px;
  padding-right: 0.5rem;
  will-change: transform;
}

.nav-right {
  display: flex;
  align-items: center;
  gap: clamp(1rem, 2.6vw, 2.25rem);
}

/* Padding, not font-size, gets these to a 44px hit area — the label
   stays small while the target is thumb-sized.

   The rule is drawn on an inner <span>, not on the <a>. On the <a> it had
   to hang off the bottom of the 44px touch box, which put it roughly 22px
   under the word and read as a stray line rather than an underline. The
   span wraps the letters, so `bottom: 0` on it is right under them.

   Identical geometry, easing, duration and direction to .nav-mail below —
   the two share one gesture. What they do NOT share is colour: these stay
   `currentColor` (bone), because a section link is somewhere you go. Only
   the mail action answers in --signal, and only it carries a standing
   hairline. */
.nav-link {
  font-family: var(--mono);
  font-weight: 500;
  font-size: var(--t-nav);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0 0.25rem;
}
.nav-link > span {
  position: relative;
  display: inline-block;
  padding-bottom: 0.32em;
}
.nav-link > span::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform 0.55s var(--ease);
}
.nav-link:hover > span::after,
.nav-link:focus-visible > span::after { transform: scaleX(1); }

@media (prefers-reduced-motion: reduce) {
  .nav-link > span::after { transition-duration: 0.01ms; }
}

/* The email action. Same typographic language as the three section links,
   so it belongs to the navigation — but it carries a standing hairline
   they don't, which is the whole difference between a place you can go and
   a thing you can do.

   The 44px target lives on the <a>; the rule lives on the inner <span> so
   it sits under the word instead of at the foot of the touch area.

   Two rules stacked, not one that changes colour: the resting hairline
   stays put while the signal rule wipes across it, which reads as an
   underline being drawn rather than a colour swapping. Same easing and
   direction as .nav-link::after, so the nav has one motion. */
.nav-mail {
  font-family: var(--mono);
  font-weight: 500;
  font-size: var(--t-nav);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  padding: 0 0.15rem;
  color: var(--bone);
  transition: color 0.5s var(--ease);
}
.nav-mail > span {
  position: relative;
  display: inline-block;
  padding-bottom: 0.32em;
}
.nav-mail > span::before,
.nav-mail > span::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1px;
}
.nav-mail > span::before { background: var(--bone-mute); }
.nav-mail > span::after {
  background: var(--signal);
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform 0.55s var(--ease);
}
.nav-mail:hover,
.nav-mail:focus-visible { color: var(--signal); }
.nav-mail:hover > span::after,
.nav-mail:focus-visible > span::after { transform: scaleX(1); }

@media (prefers-reduced-motion: reduce) {
  .nav-mail > span::after { transition-duration: 0.01ms; }
}

@media (max-width: 640px) {
  /* Three section links plus an action do not fit a phone's top bar. The
     links stay (they are the page's structure); the action goes, because
     the footer states it properly one screen down. */
  .nav-mail { display: none; }
}

/* ---------- SHARED SECTION FURNITURE ---------- */
/* THE ROW WRAPS RATHER THAN PUSHING THE PAGE WIDE (15 September 2026).
   The label at the far edge is `white-space: nowrap`, so its own width is
   its minimum: at a 320px viewport "02 Poster · Editorial · Type" measures
   223px, which with "Selected Graphic Design" beside it made the document
   369px wide inside a 320px window — the whole landing page could be
   dragged sideways. Wrapping costs nothing at any width that fits, because
   a flex row only wraps when it has to; the row gap is small so a wrapped
   label reads as a second line of the same head rather than a new block. */
.section-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.5rem 2rem;
  padding: 0 var(--gut);
  margin-bottom: clamp(2rem, 6vh, 4.5rem);
}

/* Section labels carry more weight than the small print elsewhere, so
   they step up a size and tighten their tracking a little. Same colour,
   same family — just enough to read as a heading. */
/* `.section-label` covers section eyebrows that sit outside a
   .section-head — the About one, which is a paragraph rather than a
   heading because that section's h2 is its actual title. */
.section-head .kicker,
.section-label {
  font-size: var(--t-head);
  letter-spacing: 0.16em;
}
.section-head .kicker em,
.section-label em {
  font-size: 1.25em;
  letter-spacing: -0.005em;
}

/* ---------- THE MAJOR SECTION HEAD ----------
   One section on the home page is the reason anyone is there, and it was
   announced in the same 13px tracked label as the other three. This is the
   promoted form: sentence case at display size in the site's one sans, no
   serif inside it, with the numeral demoted to a quiet label at the far
   edge. It is deliberately NOT applied to every head — if all four are
   large, none of them is the headline. */
.head-major {
  font-family: var(--sans);
  font-weight: 700;
  font-size: clamp(2.1rem, 6vw, 5rem);
  line-height: 0.95;
  letter-spacing: -0.04em;
  color: var(--bone);
  text-transform: none;
}
.section-head.is-major { align-items: flex-end; }
/* The label at the far edge steps back DOWN to the small role. The
   shared `.section-head .kicker` rule sizes labels for a head where the
   label IS the heading; here it is the footnote to one. */
.section-head.is-major .kicker {
  font-size: var(--t-label);
  letter-spacing: 0.18em;
  color: var(--bone-soft);
  white-space: nowrap;
  padding-bottom: 0.55rem;
}
.section-head.is-major .rubric { margin-right: 0.4em; }

/* ---------- THE SECOND MAJOR HEAD ----------
   The home page names two collections, and for a long time only one of
   them was named at display size — so "Selected Graphic Design" arrived in
   the same 13px tracked voice as "Expertise" and "About", and a reader
   scanning the page had nothing telling them that the covers under it are
   a different discipline rather than more of the work above.

   Both heads are display heads now, and this modifier is what keeps the
   order between them. Same family, same weight, same sentence case, one
   step down in size: about 66% of the lead head at every width. Two sizes
   is a hierarchy; two of the same size is a tie. */
.section-head.is-major.is-second .head-major {
  font-size: clamp(1.6rem, 4vw, 3.25rem);
  letter-spacing: -0.035em;
}
@media (max-width: 620px) {
  .section-head.is-major { align-items: baseline; }
  .section-head.is-major .kicker { padding-bottom: 0; }
}

.hair {
  width: 100%;
  height: 1px;
  background: var(--line);
  border: 0;
  margin: 0;
}

/* ---------- REDUCED MOTION ---------- */
@media (prefers-reduced-motion: reduce) {
  #grain { animation: none; }
  *, *::before, *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
  }
}

/* No custom cursor on touch */
@media (hover: none), (pointer: coarse) {
  #cursor-ring, #cursor-dot { display: none; }
}
