/* =========================================================================
   Uzair — portfolio hero
   tokens → base → layout → layers → components → motion → breakpoints
   Palette sampled from reference.png. The page is monochrome but for a single
   amber square in the wordmark; a second accent anywhere breaks the world.
   ========================================================================= */

/* ---------- font ----------
   Self-hosted. The page is specified to open straight off the filesystem, and
   a remote stylesheet means an offline open silently swaps the display voice
   for the platform sans. Latin subset only — it carries ASCII, the dotless ı
   (U+0131) the wordmark is built on, and the em dash. */

@font-face {
  font-family: "Archivo";
  font-style: normal;
  font-weight: 100 900;
  font-stretch: 62% 125%;
  font-display: swap;
  src: url("fonts/archivo-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193,
    U+2212, U+2215, U+FEFF, U+FFFD;
}

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

:root {
  --bone:       #EFE9E1;
  --ink:        #161616;
  --btn:        #110C0F;
  /* No grey text token: the reference's #8F918F is 2.63:1 on bone and fails AA.
     Secondary copy uses --ink-soft below instead. Don't reintroduce it. */
  /* Sampled from reference.png. The spec labels this the ghost button's
     border; a pixel scan shows the reference's border is ink and #D3CBC1 is
     its interior. The build takes the ink border but uses a white veil for
     the fill, so this now serves only rules and underlines. */
  --hairline:   #D3CBC1;
  /* The hairline two steps down, and it has exactly ONE use: the travelling
     request on the contact balancer. It exists because that request is the
     same wire carrying something rather than a foreign object laid over it -
     drawn in ink it read as a black slug on a pale drawing. Like --hairline it
     is a LINE colour only: never a fill, never text. It would fail AA as text
     on bone, and it must not become the page's third grey. */
  --hairline-load: #968C7F;
  /* Amber is now the page's only accent, and it appears exactly once: the
     wordmark's tittle. The reference's blue and pink belonged to a mark that
     has been replaced, so their tokens are gone rather than left orphaned. */
  --amber:      #F7A746;

  /* The tagline reads as set copy, not as a second headline. */
  --ink-soft:   #3D3B38;

  /* The About frame's ground, and the page's only second surface colour. It
     exists because the pinned mockup draws the portrait as a photograph in a
     frame, and a cut-out on bare bone is not a photograph. Warm, one step off
     the ground, deliberately too close to bone to read as a card — it is a
     backdrop for the figure, not a container for content. Nothing else in the
     page may use it; a tinted panel anywhere else re-breaks the One-Plane Rule
     without the brief's licence. */
  --plate:      #E1D8CD;



  /* Fill inside the outlined display glyphs. Bone, so it disappears against
     the ground and only does work where the figure is behind the type. */
  --glyph-veil: rgba(239, 233, 225, 0.45);

  /* Content gutter matches the headline's left edge; the masthead sits
     slightly wider, as it does in the reference. */
  --gutter:      clamp(20px, 7.8vw, 132px);
  --gutter-head: clamp(16px, 6vw, 100px);

  /* Headline coefficients, tuned independently per line so both terminate
     flush at the same right margin. Line 1 is 19 characters, line 2 is 18.
     Line 1 carries three more characters than "Backend Engineer" did, so its
     coefficient drops ~9.6% to hold the same measure. Retune by measurement,
     not by arithmetic on the character count: the widths are per-glyph.
     Line 2 was retuned on 2026-10-17 for "& Systems Engineer", measured flush
     to within 2px at 390, 1000, 1440, 1919x886 and 1920. */
  /* Sized from width AND height. A wide, short window (1919x886 is an ordinary
     laptop) would otherwise scale the headline off the bottom of the type block
     and into the tagline. The vh cap and the measure below are derived from the
     same reference aspect, so whichever one binds, the lines stay flush. */
  --h1a: min(14.00vw, 24.89vh);
  --h1b: min(14.10vw, 25.05vh);
  --measure: min(84.59vw, 150.85vh);
  --squeeze: 0.873;
  --stroke: max(1.2px, 0.11vw);

  /* Section display size — ONE token, three sections. About, Projects and
     Skills all set a display pair at the same rank in the page's hierarchy, so
     three independently-tuned clamps was three chances to drift, and it did:
     Projects had reached 8.2vw/132px against About's 5.3vw/76px and read as a
     different level of heading rather than the same one.

     The number is About's, because About's is the one under a real constraint —
     "THAT ACTUALLY WORK." is 19 characters and must hold one line inside the two
     columns the title spans. Skills carries the second constraint, "BUILD WITH"
     on one line in its left rail. Both are measured at this value. Re-measure
     both if you change it; neither is a matter of taste. */

  --display-2:    clamp(30px, 5.3vw, 76px);
  --display-2-sm: clamp(34px, 11.6vw, 68px);

  --ease: cubic-bezier(0.16, 1, 0.3, 1);

  /* --ease is an expo-out and it is violently front-loaded: measured, it
     delivers 49% of its travel in the first 10% of its duration and 88% by the
     30% mark. That is right for type SETTING — the line arrives as if stamped —
     and wrong for anything the eye is supposed to follow, which is why the old
     40px portrait rise was invisible rather than subtle. --rise is the curve
     for travel you are meant to watch: 6% / 25% / 50% / 69% / 82% at the same
     five marks, so the middle of the movement actually exists. */
  --rise: cubic-bezier(0.32, 0.08, 0.18, 1);
}

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

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

html {
  scrollbar-color: var(--ink) var(--bone);
  scrollbar-width: thin;
}

/* ---------- smooth scroll ----------
   Lenis's own four rules, inlined rather than shipped as a second stylesheet:
   they are 191 bytes and a separate file is one more thing that has to be
   found when the page is opened off a disk. Every selector is keyed off a
   class Lenis adds to <html> at runtime, so none of this applies unless the
   library is actually running.

   `height: auto` is the load-bearing one — Lenis needs the document to take
   its natural height, and a `height: 100%` on either element would clamp the
   scrollable range it animates through. The rest are guards it ships for
   nested scrollers, its stopped state (which the mobile menu uses) and iframes. */
html.lenis,
html.lenis body { height: auto; }
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }
.lenis.lenis-stopped { overflow: clip; }
.lenis.lenis-smooth iframe { pointer-events: none; }

body {
  margin: 0;
  /* Matched to .hero for consistency, though it is now inert: once About sits
     below the hero the content always exceeds one viewport, so this floor is
     never the binding constraint. Left in place so a future short page cannot
     end up with a body shorter than its ground. */
  min-height: 100vh;
  min-height: 100svh;
  background: var(--bone);
  color: var(--ink);
  font-family: "Archivo", ui-sans-serif, system-ui, "Segoe UI", Roboto, sans-serif;
  font-synthesis-weight: none;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  caret-color: var(--ink);
  overflow-x: hidden;
}

/* Browser surfaces are part of the design, not the platform's to decide. */
::selection { background: var(--ink); color: var(--bone); }
::-moz-selection { background: var(--ink); color: var(--bone); }

::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: var(--bone); }
::-webkit-scrollbar-thumb { background: var(--ink); border: 3px solid var(--bone); border-radius: 99px; }

:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
  border-radius: 2px;
}
:focus:not(:focus-visible) { outline: none; }

img { max-width: 100%; }
button { font: inherit; color: inherit; }

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

/* svh, not dvh, now that the page scrolls. dvh tracks the *current* viewport,
   so a collapsing mobile URL bar re-resolves it mid-scroll and the hero grows
   under the reader's thumb — the type reflows and the bottom-pinned band walks
   up the screen while they are still moving. svh is pinned to the small
   viewport: stable like vh, but unlike vh it also fits on first paint with the
   URL bar still showing, so the CTAs are never born below the fold.
   The deliberate consequence: once the URL bar retracts the viewport is taller
   than the hero, so a sliver of About shows at the bottom edge. On a page that
   scrolls that is a scroll affordance, which is why it is not corrected.
   The vh line is the fallback for engines predating svh (pre-Chrome 108 /
   Safari 15.4 / Firefox 101); they take it and stop. */
.hero {
  position: relative;
  min-height: 100vh;
  min-height: 100svh;
  overflow: hidden;
  /* `isolation: isolate` was removed when About arrived, and its absence is
     load-bearing. It made .hero a stacking context, which trapped the three
     layers inside it: the figure could then never paint over a later sibling.
     Once the figure goes position:fixed and travels into About, it has to sit
     ABOVE that section while it flies. With no stacking context here, the
     layers resolve in the root context instead — type 1, figure 2, interface 3,
     About 0 — and the ordering the design system specifies still holds exactly.
     Re-adding isolation here makes the travelling figure vanish behind About. */
}

.masthead {
  position: absolute;
  inset: 0 0 auto 0;
  /* Above the menu overlay, not below it: the burger is also the close control,
     and an overlay that paints over its own dismiss button traps the user. */
  z-index: 30;
  display: flex;
  align-items: center;
  gap: clamp(14px, 2.2vw, 38px);
  padding: clamp(18px, 3.4vh, 34px) var(--gutter-head);
}

.masthead__nav { margin-left: auto; }

/* ---------- layers ----------
   Stacking is the whole trick: type sits under the portrait, the portrait's
   own alpha channel does every cut, and the UI rides above both. No masks,
   no clip-paths, no duplicated text nodes. */

.hero__type {
  position: absolute;
  left: 50%;
  width: var(--measure);
  top: 45%;
  transform: translate(-50%, -50%);
  z-index: 1;
}

/* Anchored to the headline's datum, not the viewport floor. A bottom-pinned
   figure slides out from under the type it exists to cut once the window gets
   tall; tying it to the same 45% line keeps the overlap at every aspect, and
   the figure simply stops short of the bottom edge instead of chasing it. */
.hero__portrait {
  position: absolute;
  left: 50%;
  top: 45%;
  z-index: 2;
  transform: translate(-50%, calc(-36.1% + var(--enter-y, 0px)));
  will-change: transform;
}

/* The figure dissolves into the ground rather than meeting the viewport edge.
   Sampling reference.png straight down the sweatshirt gives #b9aa9d → #ded6cd →
   #ece4dd → #eee6dd, i.e. it reaches the bone background before the bottom
   edge. The mask reproduces that, so the portrait has no hard bottom cut.
   --fade-start stays below the headline: begin it any higher and the type
   behind would ghost through the body, inverting the one rule the whole
   composition rests on. */
.hero__portrait img {
  display: block;
  height: min(68.8vh, 50.4vw);
  width: auto;
  /* The global `img { max-width: 100% }` resolves against a shrink-to-fit
     absolute parent and fights the explicit height, squashing the portrait off
     square on tall windows. The figure must never be scaled non-uniformly. */
  max-width: none;
  --fade-start: 72%;
  /* The far stop is a variable purely so the ENTRANCE can pull it up the body
     and let the figure develop downward out of the ground. It is deliberately
     NOT derived from --fade-start: the handoff writes --fade-start inline on
     every scroll frame, and coupling the two would have silently changed the
     dissolve's shape across a travel that was measured and signed off. At rest
     it is 100%, which is exactly the constant it replaces. */
  --fade-end: 100%;
  -webkit-mask-image: linear-gradient(to bottom, #000 var(--fade-start), transparent var(--fade-end));
  mask-image: linear-gradient(to bottom, #000 var(--fade-start), transparent var(--fade-end));
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  /* --damp is the handoff's fader on the pointer parallax: 1 in the hero, 0 by
     the time the figure has landed. Without it the landed portrait keeps
     leaning toward the cursor while it is supposed to be a settled element of
     the About layout, and the lean reads as the page still being loose. */
  transform: translate3d(
    calc(var(--px, 0px) * var(--damp, 1)),
    calc(var(--py, 0px) * var(--damp, 1)),
    0
  );
}

.hero__ui {
  position: absolute;
  inset: 0;
  z-index: 3;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  padding: 0 var(--gutter) clamp(22px, 5.2vh, 50px);
  pointer-events: none;
}

.hero__band {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: clamp(20px, 3vw, 56px);
}

.lede,
.anchor,
.actions > * { pointer-events: auto; }

/* ---------- components ---------- */

/* Wordmark — the name, set plainly.

   Everything drawn is gone: the dotless ı and its amber square tittle, the 84%
   width axis, the open tracking. The mark now inherits the page's text face —
   the same Archivo the tagline is set in — at standard width and spacing, and
   is told apart from the display voice by weight and size alone. */

.wordmark {
  position: relative;
  z-index: 1;
  display: inline-block;
  /* font-family deliberately not declared: it inherits the body's Archivo, so
     the mark and the running copy can never drift onto different faces. */
  font-size: clamp(20px, 1.5vw, 28px);
  font-weight: 600;
  /* Both explicit, not merely omitted: the page sets a width axis and negative
     tracking on the faces around this one, and "standard" here has to be a
     stated value rather than an inherited accident. */
  font-stretch: normal;
  letter-spacing: normal;
  line-height: 1;
  text-decoration: none;
  color: var(--ink);
}

/* The same square module, reused as a live marker. Amber means exactly two
   things on this page and they are both true statements about the person:
   this is him, and he is open to work. */
.live {
  display: inline-block;
  width: 0.62em;
  height: 0.62em;
  margin-right: 0.5em;
  background: var(--amber);
  vertical-align: baseline;
}

@media (prefers-reduced-motion: no-preference) {
  .live { animation: breathe 2.8s ease-in-out infinite; }
}

@keyframes breathe {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.35; }
}

/* Nav */

.navlist {
  display: flex;
  gap: clamp(16px, 2.55vw, 44px);
  margin: 0;
  padding: 0;
  list-style: none;
}

.navlist a {
  position: relative;
  display: inline-block;
  padding: 4px 0;
  color: var(--ink);
  font-size: clamp(14px, 1.14vw, 19px);
  font-weight: 500;
  text-decoration: none;
}

.navlist a::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1.5px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.35s var(--ease);
}

.navlist a:hover::after { transform: scaleX(1); }

/* Profiles — the masthead's terminal weight, where the contact pill used to sit.

   The address is gone from here: it is already the hero's terminal action, and
   printing it twice on one viewport spends the page's single strongest call on
   a duplicate. What replaces it must not read as two more nav items, so it
   shares nothing with them — solid ink square against bare text, glyph against
   word, lift-and-warm against an underline sweep. The square is the page's own
   module, borrowed from the wordmark's tittle and the availability marker. */

.social {
  display: flex;
  gap: clamp(8px, 0.7vw, 12px);
  /* The nav is a list and this is not part of it. The gap is wider than the
     navlist's own so the break between the two groups reads before the
     difference in form does. */
  margin-left: clamp(20px, 2.4vw, 40px);
}

.social__link {
  display: grid;
  place-items: center;
  width: clamp(38px, 3.05vw, 50px);
  height: clamp(38px, 3.05vw, 50px);
  background: var(--btn);
  color: var(--bone);
  transition: transform 0.35s var(--ease), background-color 0.3s ease;
}

.social__link svg {
  display: block;
  width: 46%;
  height: 46%;
}

.social__link:hover { transform: translateY(-2px); background: #241a1e; }

/* Menu trigger — two bars, not three: the page's language is reductive, and two
   fold into a cross without a spare line to hide. Phone only. */
.burger {
  display: none;
  position: relative;
  width: 46px;
  height: 46px;
  margin-left: auto;
  margin-right: -10px;
  padding: 0;
  background: none;
  border: 0;
  cursor: pointer;
}

.burger__box {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 7px;
}

.burger__bar {
  display: block;
  width: 26px;
  height: 2px;
  background: var(--ink);
  transition: transform 0.4s var(--ease), opacity 0.25s ease;
}

.is-menu-open .burger__bar:first-child { transform: translateY(4.5px) rotate(45deg); }
.is-menu-open .burger__bar:last-child { transform: translateY(-4.5px) rotate(-45deg); }

/* Full-ground menu. Bone on bone — no scrim, no panel, no card: the page simply
   becomes the menu, which is the only move consistent with a flat world. */
.menu {
  position: fixed;
  inset: 0;
  z-index: 20;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: clamp(28px, 6vh, 56px);
  padding: clamp(90px, 14vh, 130px) 22px clamp(28px, 5vh, 48px);
  background: var(--bone);
  opacity: 0;
  visibility: hidden;
  /* visibility switches with zero duration, delayed only on the way out so the
     fade can finish. Transitioning it over 0.4s instead would leave the panel
     computing as hidden at the instant it opens — long enough that moving focus
     into it silently fails. */
  transition: opacity 0.4s var(--ease), visibility 0s linear 0.4s;
}

.is-menu-open .menu {
  opacity: 1;
  visibility: visible;
  transition: opacity 0.4s var(--ease), visibility 0s linear 0s;
}

.menu__nav ul {
  margin: 0;
  padding: 0;
  list-style: none;
}

.menu__nav a {
  display: block;
  padding: clamp(9px, 1.5vh, 15px) 0;
  color: var(--ink);
  font-size: clamp(34px, 11vw, 54px);
  font-weight: 800;
  font-stretch: 70%;
  letter-spacing: -0.03em;
  line-height: 1.02;
  text-decoration: none;
}

.menu__foot {
  border-top: 1px solid var(--hairline);
  padding-top: clamp(14px, 2.4vh, 22px);
}

.menu__meta {
  margin: 0 0 10px;
  color: var(--ink-soft);
  font-size: 14px;
}

.menu__mail {
  display: inline-block;
  color: var(--ink);
  font-size: clamp(15px, 4.4vw, 19px);
  font-weight: 600;
  text-decoration: underline;
  text-decoration-color: var(--hairline);
  text-decoration-thickness: 1px;
  text-underline-offset: 5px;
}

/* Same squares, one step down: in the menu they follow the address rather than
   terminating a bar, so they carry less weight than they do in the masthead. */
.social--menu {
  margin-left: 0;
  margin-top: 18px;
}

.social--menu .social__link {
  width: 42px;
  height: 42px;
}

/* Headline — two spans in one h1. Reads as a single continuous string to
   assistive tech; the visual break is presentational. */

.headline {
  margin: 0;
  font-weight: 900;
  font-stretch: 62%;
  line-height: 0.85;
  letter-spacing: -0.04em;
}

/* Archivo's width axis bottoms out at 62% (Google serves `font-stretch: 62%
   125%`), and at that floor it is still wider per unit cap-height than the
   reference's face. Holding both lines flush therefore costs display size —
   ~13% of cap height. --squeeze buys it back: the type is set larger and
   condensed the remaining distance geometrically. */
.headline__line {
  display: block;
  /* Sized to its own text, not to the container. The entrance clip-path insets
     from the BORDER box, so a line whose glyphs overflow a narrower box would
     be sliced mid-word the moment the squeeze makes the text wider than its
     parent. */
  width: max-content;
  white-space: nowrap;
  /* line-height .85 pulls the box in tighter than the font's own descent, so
     the tails of g, y and p hang below the border box — and the entrance
     clip-path insets from that box, shearing them off. The padding lets the
     box contain them; the matching negative margin keeps layout unchanged. */
  padding-bottom: 0.3em;
  margin-bottom: -0.3em;
  /* Same fix on the right. A glyph can ink past its own advance width — the
     flag of a final "r" does, plus half the outline stroke — and both the
     clip-path and the outline's draw mask paint only inside the border box, so
     "Engineer" lost the right edge of its last letter. */
  padding-right: 0.08em;
  margin-right: -0.08em;
  transform: scaleX(var(--squeeze));
  transform-origin: left center;
}

.headline__line--solid {
  font-size: var(--h1a);
  color: var(--ink);
}

/* The glyph interiors carry a bone veil rather than being fully transparent.
   Over the ground it is bone-on-bone and invisible, so the line still reads as
   pure outline; over the figure it calms the photograph inside the letterforms
   so the strokes stay legible against skin and fabric.

   This is the shippable half of "blur the backdrop inside the letters".
   background-clip: text does NOT clip backdrop-filter — verified, it blurs the
   whole rectangle. Only an SVG <mask> containing <text> clips it, and that
   means duplicating the headline in SVG and keeping its size, width axis and
   scaleX in sync with the HTML at every breakpoint. The veil buys the same
   separation for one declaration and cannot drift. */
.headline__line--outline {
  font-size: var(--h1b);
  color: var(--glyph-veil);
  -webkit-text-stroke: var(--stroke) var(--ink);
}

.brk { display: none; }

/* The adjustable word space. Holds a real space character so the heading still
   reads as one sentence, but its rendered width is a token. */
/* The word space, expressed as a token rather than a literal space character.
   0.12em reproduces the natural space of this cut once the -0.04em tracking
   either side is accounted for, and the flush coefficients are tuned against
   it. It holds a real space so the heading still reads as one sentence. */
.wgap {
  display: inline-block;
  width: 0.12em;
}

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

/* Tagline block — the sentence and the two facts that qualify it. */

.lede {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: clamp(9px, 1.2vh, 15px);
}

/* The line break is authored in the markup, after "systems". No max-width may
   second-guess it, or the tagline silently rewraps to three lines. */
.tagline {
  margin: 0;
  color: var(--ink-soft);
  font-size: clamp(15px, 1.45vw, 25px);
  font-weight: 400;
  line-height: 1.36;
  letter-spacing: -0.01em;
}

/* Copy-email anchor — the terminal action. Replaces the reference's
   trusted-by row, which named clients that do not exist. */

.anchor {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: clamp(7px, 0.9vh, 11px);
}

/* One secondary-fact voice, used twice: place and availability under the
   tagline, track record above the address. Same size and colour on both sides
   of the band, so the two columns read as one register rather than as a caption
   and a label. */
.meta {
  display: flex;
  align-items: baseline;
  gap: clamp(10px, 1vw, 18px);
  margin: 0;
  color: var(--ink-soft);
  font-size: clamp(12px, 1.02vw, 17px);
  font-weight: 400;
  letter-spacing: -0.005em;
}

.meta__nowrap { white-space: nowrap; }

/* The record sits above the rule, the address below it. The rule separates the
   claim from the action it earns. */
.anchor__meta { justify-content: flex-end; }

.anchor__row {
  padding-top: clamp(8px, 1vh, 13px);
  border-top: 1px solid var(--hairline);
}

.anchor__btn {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  padding: 0;
  background: none;
  border: 0;
  font-size: clamp(14px, 1.32vw, 23px);
  font-weight: 600;
  font-stretch: 92%;
  letter-spacing: -0.015em;
  cursor: pointer;
}

.anchor__label {
  text-decoration: underline;
  text-decoration-color: var(--hairline);
  text-decoration-thickness: 1px;
  text-underline-offset: 5px;
  transition: text-decoration-color 0.3s ease;
}

.anchor__btn:hover .anchor__label { text-decoration-color: var(--ink); }

.anchor__icon {
  display: block;
  width: 1.05em;
  height: 1.05em;
  flex: none;
}
.anchor__icon svg { display: block; width: 100%; height: 100%; }

.anchor__icon-copy { transition: opacity 0.2s ease; }
.anchor__icon-check {
  stroke-dasharray: 22;
  stroke-dashoffset: 22;
}

.anchor.is-copied .anchor__icon-copy { opacity: 0; }
/* Confirmation stays in ink: the page's one accent belongs to the wordmark
   alone, and a colour that weak on bone cannot carry a state message anyway. */
.anchor.is-copied .anchor__icon-check {
  stroke-dashoffset: 0;
  transition: stroke-dashoffset 0.35s var(--ease);
}

.anchor__feedback {
  min-height: 1em;
  color: var(--ink);
  font-size: clamp(11px, 0.8vw, 13px);
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  opacity: 0;
  transition: opacity 0.25s ease;
}

.anchor.is-copied .anchor__feedback,
.anchor.is-copy-failed .anchor__feedback { opacity: 1; }

/* CTAs */

.actions {
  display: flex;
  justify-content: center;
  gap: clamp(10px, 1.15vw, 19px);
  /* This gap is the only thing holding the band up: the UI column is bottom-
     pinned, so shrinking the gap drops the tagline and the anchor toward the
     CTAs rather than moving the CTAs up. Tightened again so both side blocks
     sit lower, further clear of the headline's descenders. */
  margin-top: clamp(16px, 3.2vh, 34px);
}

.btn {
  display: inline-flex;
  align-items: center;
  gap: clamp(18px, 2.1vw, 38px);
  padding: clamp(14px, 1.2vw, 22px) clamp(20px, 2vw, 35px);
  border: 1px solid transparent;
  border-radius: 6px;
  font-size: clamp(14px, 1.2vw, 21px);
  font-weight: 500;
  text-decoration: none;
  transition: transform 0.35s var(--ease), background-color 0.3s ease, border-color 0.3s ease;
}

.btn--filled { background: var(--btn); color: var(--bone); }
.btn--filled:hover { background: #241a1e; transform: translateY(-2px); }

/* Ink border over a light veil, as the reference draws it. The veil lifts the
   button off the sweatshirt without hiding it, and reads as nothing at all
   where the button sits on bone. */
.btn--ghost {
  border-color: var(--ink);
  background: rgba(255, 255, 255, 0.2);
  -webkit-backdrop-filter: blur(5px);
  backdrop-filter: blur(5px);
  color: var(--ink);
}
.btn--ghost:hover {
  background: rgba(255, 255, 255, 0.42);
  transform: translateY(-2px);
}

.btn__arrow {
  width: clamp(24px, 2.1vw, 36px);
  height: auto;
  flex: none;
  transition: transform 0.35s var(--ease);
}

.btn:hover .btn__arrow { transform: translateX(4px); }

/* Section marker — shared. One ruled line: label at the left terminal, numeral
   at the right, rule beneath both. Promoted out of .about the moment a second
   section wanted it; every future section gets its eyebrow and its index from
   here, so the numbering can never drift onto two different scales. */
.marker {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 20px;
  padding-bottom: clamp(8px, 1vh, 13px);
  border-bottom: 1px solid var(--hairline);
}

.marker__eyebrow {
  font-size: clamp(10px, 0.78vw, 13px);
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink);
}

/* Outlined, matching the display line's device rather than being a second
   solid mark competing with the heading below it. */
.marker__index {
  font-size: clamp(26px, 2.6vw, 44px);
  font-weight: 900;
  font-stretch: 62%;
  line-height: 0.8;
  letter-spacing: 0.02em;
  color: transparent;
  -webkit-text-stroke: 1px var(--hairline);
}

/* The display pair — shared. Weight against contour: one line stamped solid,
   the next drawn as contour only. It is the page's signature device, used by
   the hero h1, the About title and every project name, so it lives here rather
   than being re-specified per section.

   The hero is deliberately NOT on this class. Its outline line carries an
   animated mask-image for the entrance draw and a --stroke that scales with the
   viewport; folding it in here would put section-sized rules on the one element
   whose entrance depends on its own. */
.dline { display: block; }
.dline--solid { color: var(--ink); }

/* Fill stays fully transparent, unlike the hero's bone veil: the veil exists to
   calm the photograph inside the hero's counters, and nothing sits behind these
   letters except the ground. */
.dline--outline {
  color: transparent;
  -webkit-text-stroke: max(1px, 0.09vw) var(--ink);
}

/* ---------- about ----------
   Built to a pinned mockup, which is the authority for this section and
   deliberately overrides four rules the hero established. Recorded here so the
   next person reads them as decisions rather than as drift:

     1. The values panel is a bordered box. The One-Plane Rule said no panels.
     2. Amber goes from one instance to six. The Single Square Rule said two.
     3. Uppercase labels are used as eyebrows and stat labels. The system
        reserved uppercase for transient live status only.
     4. Values carry 01/02/03 indices, which the system had banned.

   All four are the brief's, not habit. DESIGN.md has been rewritten to match
   rather than left contradicting the code. What is NOT relaxed: the ground is
   still bone, the type is still one family, depth is still photographic, and
   the figure's alpha is still the only thing that cuts type. */

.about {
  position: relative;
  /* Positioned at 0 so the fixed figure at z-index 2 paints over it while it
     travels, and so About still sits beneath all three hero layers. */
  z-index: 0;
  /* A full viewport, and it is load-bearing rather than cosmetic. Removing the
     values panel took most of this section's height with it, and the handoff
     needs enough page beneath the hero to finish its landing before the
     document runs out of scroll — the travel now ends when the frame clears the
     viewport, and that scroll position has to exist. Centring the content then
     keeps the section composed rather than top-heavy in the taller box. */
  min-height: 100vh;
  min-height: 100svh;
  display: grid;
  align-content: center;
  padding: clamp(56px, 9vh, 104px) var(--gutter) clamp(64px, 10vh, 120px);
}


/* The default is the frameless composition, and that is deliberate. The frame
   exists ONLY to catch the travelling figure; with no travel it is a second
   static copy of the portrait the hero showed a screen earlier. Phones,
   reduced motion, a failed GSAP load and no-JS all land here, so the honest
   default is the two-column layout, and the frame is the addition. */
.about__grid {
  display: grid;
  grid-template-columns: minmax(0, 1.12fr) minmax(0, 0.88fr);
  grid-template-areas:
    "title title"
    "text  stats";
  column-gap: clamp(26px, 4vw, 74px);
  row-gap: clamp(20px, 3.2vh, 40px);
  margin-top: clamp(20px, 3vh, 38px);
}

/* .has-handoff is set by script.js only once GSAP, ScrollTrigger, a ≥768px
   viewport and no reduced-motion preference are all confirmed — i.e. only when
   a figure will really arrive here. */
.has-handoff .about__grid {
  grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.6fr) minmax(190px, 1.1fr);
  grid-template-areas:
    "title title frame"
    "text  stats frame";
}

/* Title — the hero's solid/outline pairing at roughly two thirds the size.
   No --squeeze: that device exists to hold the h1 flush to both margins, and
   these two lines are deliberately ragged right. */
.about__title {
  grid-area: title;
  margin: 0;
  font-weight: 900;
  font-stretch: 62%;
  /* Shared with .work__heading and .skills__heading — see --display-2. This
     section is where the number came from: "THAT ACTUALLY WORK." is 19
     characters and has to hold one line inside the two columns the title spans;
     at 7vw it broke to three lines and the mockup's two-line block became a
     ragged stack. Re-measure this if either line's copy changes — the
     constraint is the line fitting, not the number itself. */
  font-size: var(--display-2);
  line-height: 0.9;
  /* Looser than the hero h1's tracking on purpose: the h1 is set much larger,
     and what reads as tight there reads as collided at this size. Measured —
     the longer line still holds one line down to 768px, at its tightest
     ~11% clear of the column; the phone breakpoint wraps it to two lines at
     both this value and the old -0.025em, so that wrap is the layout, not the
     tracking. Loosening much further is what would break the desktop fit. */
  letter-spacing: -0.008em;
  text-transform: uppercase;
}

.about__text { grid-area: text; }

.about__text p {
  margin: 0 0 1.05em;
  /* Widened with the type. At 42ch the larger size wrapped to five short
     lines and the column read as a narrow strip beside the stats. */
  max-width: 46ch;
  color: var(--ink-soft);
  font-size: clamp(15.5px, 1.22vw, 21px);
  line-height: 1.58;
}

.about__meta {
  margin: 0;
  gap: clamp(12px, 1.4vw, 22px);
  font-size: clamp(12px, 0.92vw, 15px);
}

/* The availability marker breathes because it reports a live state. A location
   does not change while you read it, so its square is the same module held
   still — same meaning, no false liveness. */
.live--static { animation: none !important; }

/* Stats */

/* Proportion here is the whole job. Three figures over three labels is a
   rhythm, and it fails in two specific ways: a label too small against its
   figure reads as a caption that fell off, and rows that only wrap their own
   content leave the rules floating at uneven distances.

   So: the label is pulled up tight under its figure — they are one object —
   and the space is spent BELOW, between that object and its rule. Tight group,
   generous separation. The rows then distribute across the full height of the
   frame beside them rather than bunching at the top, which is what makes the
   column read as a measured scale instead of a stack. */
.about__stats {
  grid-area: stats;
  align-self: stretch;
  margin: 0;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: clamp(14px, 2vh, 26px);
}

.about__stat {
  display: flex;
  flex-direction: column;
  padding-bottom: clamp(13px, 1.7vh, 20px);
  border-bottom: 1px solid var(--hairline);
}

/* Tabular figures so 3+, 15+ and 10+ set on the same rhythm — proportional
   digits give the two-digit rows a visibly different colour and weight. */
.about__figure {
  font-size: clamp(32px, 3.5vw, 58px);
  font-weight: 900;
  font-stretch: 62%;
  line-height: 0.8;
  letter-spacing: -0.035em;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}

.about__statlabel {
  /* Tight to the figure: a third of the label's own line, so the pair reads as
     one unit and every gap larger than this one is a separation. */
  margin: 0.62em 0 0;
  font-size: clamp(9.5px, 0.72vw, 12px);
  font-weight: 700;
  line-height: 1.42;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

/* The landing frame */

.about__frame {
  /* Absent unless a figure is coming. display:none, not visibility:hidden —
     nothing needs to measure this box when the handoff is off, and reserving
     empty space for an image that will never arrive is the redundancy this is
     here to remove. The handoff measures it only under .has-handoff, where it
     is a rendered box again. */
  display: none;
  grid-area: frame;
  align-self: end;
  width: 100%;
  /* Taken from the mockup's own proportion. It is also the number the handoff
     computes its cover-scale and clip from, so changing it changes the landing
     — the JS reads this box rather than assuming anything about it. */
  aspect-ratio: 7 / 8;
  overflow: hidden;
  background: var(--plate);
  /* Arched at the head, square at the foot. The curve belongs to the top
     because that is where the subject's head sits — it frames him rather than
     just softening a rectangle — and the flat bottom keeps the figure standing
     on a line instead of floating in a lozenge.

     The handoff READS this value back out of computed style and interpolates
     the travelling figure's clip-path to match, so this single declaration is
     the source of truth for both the static frame and the landing. Change it
     here and the arriving figure follows; there is no second number in the JS
     to keep in sync. */
  border-radius: clamp(60px, 11vw, 200px) clamp(60px, 11vw, 200px) 0 0;
}

.has-handoff .about__frame { display: block; }

.about__portrait {
  display: block;
  width: 100%;
  height: 100%;
  /* cover + top means the head stays in frame at every width and the crop is
     taken off the bottom, which is the one place this figure can lose. */
  object-fit: cover;
  object-position: 50% 0%;
}

/* ---------- work ----------
   Built to "Projects section.png". About argues; this evidences, so it speaks
   in the same voice one octave down: same marker, same solid/outline display
   pair, same amber square, same hairlines.

   Two deliberate breaks with the hero's system, both the brief's and both
   recorded in DESIGN.md rather than left to read as drift:

     1. THE SHOT. A screenshot in dark browser chrome, with a radius and a
        shadow. That is a second plane, and the One-Plane Rule forbade it. It is
        unavoidable here: the section's entire job is showing work, and a
        screenshot floating on bare bone reads as a broken image rather than as
        a product. Colour arrives with it too — the screenshots are full-colour
        interfaces — so the traffic lights are allowed to be traffic lights.
     2. Amber goes from About's six instances to roughly fourteen, as list
        separators. The Single Square Rule is now formally retired; what governs
        instead is that amber never carries meaning it cannot support.

   What is NOT relaxed: ground is still bone, type is still one family, and
   nothing here introduces a second typeface or a third neutral. */

.work {
  position: relative;
  /* Beneath the travelling hero figure, exactly as About is. The handoff pins a
     fixed element at z-index 2; any section that forgets this gets painted
     over while the figure flies. */
  z-index: 0;
  padding: clamp(64px, 11vh, 128px) var(--gutter) clamp(64px, 10vh, 120px);

  /* One token, two consumers: the numeral's size and the top of the rule that
     drops out of it. Declared once so the rail can never come adrift from the
     glyph it descends from. */
  --num-size: clamp(26px, 2.6vw, 46px);
  --num-col: clamp(38px, 3.6vw, 64px);
  --name-size: clamp(28px, 3.5vw, 58px);

  /* The section's measure. The gutter is a percentage, so above ~1600px the row
     kept widening and the only things in it that widen with it are the gaps:
     the paragraph stops at 44ch and the shot at its own cap, so the channel
     between them ran to 556px at 1920 and 1196px at 2560. Two objects that far
     apart stop reading as one row. Capped and centred, the channel holds at
     ~200px at every width above the cap. Below ~1600 nothing changes — the
     gutter is still narrower than this, so the cap does not engage. */
  --work-max: 1360px;
}

/* The rows only. The marker and the heading keep the page's full gutter width,
   by owner decision — the cap is there to stop the row's two objects drifting
   apart, and the head has no such pair to hold together. */
.work__list {
  max-width: var(--work-max);
  margin-inline: auto;
}

/* Head — display pair left, blurb parked in the right column against the
   outlined line, as the comp draws it. Not centred and not full width: the
   asymmetry is what stops the section opening like a document. */
.work__head {
  display: grid;
  grid-template-columns: minmax(0, auto) minmax(0, 1fr);
  align-items: end;
  column-gap: clamp(26px, 4vw, 80px);
  margin: clamp(22px, 3.4vh, 44px) 0 clamp(34px, 5vh, 66px);
}

.work__heading {
  margin: 0;
  font-weight: 900;
  font-stretch: 62%;
  font-size: var(--display-2);
  line-height: 0.86;
  letter-spacing: -0.012em;
  text-transform: uppercase;
}

.work__blurb {
  margin: 0 0 clamp(4px, 1.4vh, 16px);
  max-width: 34ch;
  color: var(--ink-soft);
  font-size: clamp(14px, 1.12vw, 19px);
  line-height: 1.55;
}

/* List */

.work__list {
  /* `auto`, not `0`. This rule is declared after the block that centres every
     child of the section, at equal specificity, so a flat `margin: 0` here wins
     and silently un-centres the rows alone — the heading moved to the middle
     and the projects stayed at the left gutter. */
  margin: 0 auto;
  padding: 0;
  list-style: none;
}

.work__item {
  display: grid;
  /* The shot has come down twice. It began at 1.42fr against the copy's 0.9 —
     58% of the measure, 709px at 1440, which made each row ~570px tall and the
     dark chrome the loudest thing in the section. Now the copy takes the larger
     track and the shot ~44% (about 535px), which is still well above the size a
     reader needs to recognise a page they may already know.

     The type is deliberately untouched: the row read as oversized because of
     the picture, not the words, and scaling the titles down with it would have
     cost the section its voice to fix a problem the type never had. */
  grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
  align-items: center;
  /* The gap widens as the shot narrows, so the reclaimed space is split between
     the copy column and the channel between them rather than all landing in a
     paragraph that is already capped at 44ch. */
  column-gap: clamp(26px, 4.6vw, 88px);
  /* Vertical too. A shorter shot in a row still padded for a tall one leaves
     the rule above and below floating away from the content they separate. */
  padding: clamp(30px, 4.6vh, 56px) 0;
}

/* Between rows only. The head already closes with the marker's rule and a
   second one directly beneath it would read as a double rule. */
.work__item + .work__item { border-top: 1px solid var(--hairline); }

/* Every row is laid out the same way: copy left, shot right. The rows used to
   alternate, as the pinned comp draws them, and that was revised by owner
   decision — recorded in DESIGN.md rather than left looking like drift.

   The reason is the reader's task. A screenshot is scanned as an object and
   costs nothing by moving; a text block needs a fixed entry point. Alternating
   put the project NAME at roughly x=198 on odd rows and x=705 on even ones — a
   ~500px horizontal reset, four times, on the one element someone comparing
   projects is actually hunting for. It also broke the 01/02/03/04 rail into a
   zigzag, when that rail exists precisely to be read as an index.

   What is given up is rhythm. If four identical rows ever start reading as a
   table, break the pattern ONCE and deliberately — a featured first row, or one
   shot run wider — rather than reinstating an oscillation. */

/* Body — one rule, not two. The divider that hugged the title's left edge is
   gone; the rail that drops out of the numeral stays, because it is what ties
   the block of copy to the number that counts it. */
.work__body {
  position: relative;
  display: grid;
  grid-template-columns: var(--num-col) minmax(0, 1fr);
  column-gap: clamp(10px, 1.2vw, 22px);
  row-gap: 0;
}

.work__body::before {
  content: "";
  position: absolute;
  /* Centre of the numeral column, so the rule appears to fall out of the glyph
     rather than to sit beside it. */
  left: calc(var(--num-col) / 2);
  /* The numeral is centred in row 1 now, not top-aligned, so the rule can no
     longer start at the numeral's own height. Row 1 is the name's line box
     (`--name-size` x 0.92) and the numeral's box is `--num-size` x 0.8 centred
     inside it, so the glyph's foot is at half their sum. The 14px is breath. */
  top: calc((var(--name-size) * 0.92 + var(--num-size) * 0.8) / 2 + 14px);
  bottom: clamp(2px, 0.6vh, 8px);
  width: 1px;
  background: var(--hairline);
}

.work__num {
  grid-column: 1;
  grid-row: 1;
  /* Centred in its gutter and against the name beside it. Row 1 is exactly the
     name's height now that the name is one line, so centring the numeral's box
     in that row sets the two optically level — the numeral is the smaller of
     the two, and top-aligning it left it floating. */
  justify-self: center;
  align-self: center;
  margin: 0;
  font-size: var(--num-size);
  font-weight: 900;
  font-stretch: 62%;
  line-height: 0.8;
  letter-spacing: 0.02em;
  font-variant-numeric: tabular-nums;
  color: transparent;
  -webkit-text-stroke: 1px var(--hairline);
}

/* Everything except the numeral shares the second column, so the copy hangs
   off the title's left edge rather than off the numeral's. With the divider
   gone, that edge is the column's own — no padding stands in for a line that
   is no longer drawn. */
.work__name,
.work__desc,
.work__tech,
.work__cta { grid-column: 2; }

.work__name {
  grid-row: 1;
  margin: 0;
  font-weight: 900;
  font-stretch: 62%;
  font-size: var(--name-size);
  line-height: 0.92;
  letter-spacing: -0.008em;
  text-transform: uppercase;
}

/* The separated list. The square is the page's own module, not a bullet: same
   shape as the availability marker, held still because a list is not a live
   state. */
.work__tech {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  margin: 0;
  /* Explicitly zero, not merely undeclared. `list-style: none` does not touch
     the UA's own padding-inline-start; leaving it off indented the list 40px
     from the copy it belongs under. */
  padding-left: 0;
  list-style: none;
}

/* Trailing, and symmetrically margined. Two lessons live in this one rule.
   Symmetric margins because a one-sided margin puts the square hard against one
   neighbour and a full word-space from the other — it reads as punctuation
   attached to the wrong word. Trailing because the list wraps on a phone, and a
   separator drawn at the START of its <li> travels with that <li> onto the next
   line, where a lone square opening a line reads as a bullet. Drawn at the END,
   it stays behind on the line it separates. */
.work__tech li:not(:last-child)::after {
  content: "";
  display: inline-block;
  width: 0.44em;
  height: 0.44em;
  margin: 0 0.8em 0.06em 0.8em;
  background: var(--amber);
  vertical-align: middle;
}

/* The sentence now follows the name directly, so it carries the breath the tag
   row used to hold — a description set tight under a 58px title reads as a
   subtitle rather than as the answer to "what is this". */
.work__desc {
  margin: clamp(18px, 2.7vh, 30px) 0 0;
  max-width: 44ch;
  color: var(--ink-soft);
  font-size: clamp(14px, 1.08vw, 18px);
  line-height: 1.62;
}

.work__tech {
  margin-top: clamp(14px, 2.1vh, 24px);
  font-size: clamp(12px, 0.92vw, 15.5px);
  color: var(--ink-soft);
  letter-spacing: -0.005em;
}

/* The row's one action, and it is set as a label rather than a button: a
   filled control here would compete with the shot beside it for the row's
   single focal point. */
.work__cta {
  justify-self: start;
  display: inline-flex;
  align-items: center;
  gap: clamp(12px, 1.4vw, 22px);
  margin-top: clamp(20px, 3vh, 34px);
  padding-bottom: 5px;
  border-bottom: 1px solid var(--ink);
  color: var(--ink);
  font-size: clamp(11px, 0.84vw, 14px);
  font-weight: 700;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  text-decoration: none;
  transition: gap 0.35s var(--ease);
}

.work__arrow {
  width: clamp(22px, 1.9vw, 34px);
  height: auto;
  flex: none;
  transition: transform 0.35s var(--ease);
}

.work__cta:hover .work__arrow { transform: translateX(4px); }

/* Open-source rows carry a second, quieter way out: the repository. Same
   label voice as the CTA, but soft ink and no rule, so the case study stays
   the row's one primary action. */
.work__actions {
  grid-column: 2;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: clamp(14px, 1.2vw, 20px) clamp(22px, 2.4vw, 40px);
  margin-top: clamp(20px, 3vh, 34px);
}

.work__actions .work__cta { margin-top: 0; }

.work__source {
  display: inline-flex;
  align-items: center;
  gap: 0.7em;
  padding-bottom: 6px;
  border-bottom: 1px solid transparent;
  color: var(--ink-soft);
  font-size: clamp(11px, 0.84vw, 14px);
  font-weight: 700;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  text-decoration: none;
  transition: color 0.25s ease, border-color 0.25s ease;
}

.work__source:hover {
  color: var(--ink);
  border-bottom-color: var(--hairline);
}

.work__source-mark {
  width: 1.15em;
  height: 1.15em;
  flex: none;
}

/* The shot */

.work__shot {
  margin: 0;
  min-width: 0;
  /* A screenshot has a size past which more pixels buy nothing. The column is
     fr-based, so without this the shot keeps growing with the monitor — 534px
     at 1440, 729px at 1920, 1000px+ on an ultrawide — and the section reads as
     oversized on exactly the screens a recruiter is most likely to be using.
     Capped, it stays flush to the right margin and the surplus becomes gutter.
     620px is ~42% of a 1490px-wide source: small enough to sit as evidence
     beside the copy, large enough to recognise a page you have seen before.
     With `--work-max` in place the track resolves to almost exactly this at
     every width above the cap, so this is now a guard rather than the binding
     constraint — it is what stops the shot running away again if the column
     ratio is ever retuned. */
  max-width: 620px;
  margin-left: auto;
}

.shot {
  overflow: hidden;
  /* {rounded.screen}. Fixed, not a clamp: this radius is depicting a real
     browser window's corner, and a window corner does not grow with the
     viewport. It was `clamp(7px, 0.7vw, 12px)`, which resolved to 10.08px at
     1440 anyway — the token is the same shape with a reason attached. */
  border-radius: 10px;
  background: #0E0E0E;
  /* The section's one shadow, and it is doing a job the border cannot: lifting
     a dark rectangle off a warm ground so it reads as a screen rather than as a
     hole cut in the page. Warm-tinted, not neutral black — a grey shadow on
     bone turns the ground beneath it faintly blue. */
  box-shadow: 0 18px 44px -20px rgba(58, 44, 32, 0.45);
}

.shot__bar {
  display: flex;
  align-items: center;
  gap: clamp(4px, 0.42vw, 7px);
  padding: clamp(7px, 0.72vw, 12px) clamp(10px, 1vw, 16px);
  background: #1C1C1C;
}

.shot__dot {
  width: clamp(5px, 0.5vw, 9px);
  aspect-ratio: 1;
  border-radius: 50%;
}

/* Desaturated from the real traffic lights on purpose. At full saturation three
   dots outrank the page's single amber square, and the accent stops meaning
   "available for work" the moment something louder shares the screen. */
.shot__dot:nth-child(1) { background: #C9695F; }
.shot__dot:nth-child(2) { background: #C99A4F; }
.shot__dot:nth-child(3) { background: #6E9A62; }

.shot img {
  display: block;
  width: 100%;
  /* height:auto is load-bearing, not tidiness. The markup carries width/height
     attributes for layout stability, and those map to presentational hints —
     which means `height` is a DEFINITE 866px unless an author rule says
     otherwise. With both axes definite, aspect-ratio is ignored outright: the
     box became 709x866, object-fit:cover then scaled the shot to 1:1 to cover
     it, and ~390px was cropped off each side of the screenshot. */
  height: auto;
  /* 16/9 is WIDER than either source (Corlee 1.72, Optimus 1.63), so cover
     scales to fill the width and takes its crop off the bottom — a sliver, 13px
     and 36px respectively at a 709px column. A 16/10 box is narrower than both
     and crops horizontally instead: ~28px off each side, which was enough to
     cut the "corlee" wordmark down to "orlee". Never let this box get narrower
     than the widest screenshot it has to hold. */
  aspect-ratio: 16 / 9;
  object-fit: cover;
  /* Top-anchored: these are page screenshots, and everything that identifies
     them — masthead, headline, first row of data — is in the top third. The
     crop is always taken off the bottom. */
  object-position: 50% 0;
}

/* The film — a project's showcase video, playing inside the same window
   chrome as a screenshot, so a row with a film and a row with a still
   (RedAuth) read as the same object. Shared with the case study's lead.
   player.js builds the controls; without it the <video> keeps the browser's
   own, which is why every rule that hides or restyles anything is scoped to
   .is-enhanced. */

.film {
  position: relative;
  aspect-ratio: 16 / 9;
  background: #0E0E0E;
  overflow: hidden;
  /* Lenis and the row reveal both transform ancestors; isolating the film
     keeps its overlays compositing as one layer with the video. */
  isolation: isolate;
}

.film__video {
  display: block;
  width: 100%;
  /* height:100% rather than auto: the box is sized by .film's aspect-ratio,
     and the video fills it. The same presentational-hint trap as .shot img
     applies if width/height attributes are ever added to the element. */
  height: 100%;
  object-fit: cover;
  background: #0E0E0E;
}

.film.is-enhanced .film__video { cursor: pointer; }
.film.is-idle .film__video { cursor: default; }

/* The start control. A filled control, so it is Control Black like every
   other filled surface, and it carries the hover rise those do. The time
   beside the label is the one thing a recruiter wants to know before
   pressing it. A hairline of bone keeps it legible on the dark posters. */
.film__start {
  position: absolute;
  left: 50%;
  top: 50%;
  z-index: 3;
  display: none;
  align-items: center;
  gap: 12px;
  padding: 13px 18px 13px 15px;
  border: 1px solid rgba(239, 233, 225, 0.16);
  border-radius: 6px;
  background: var(--btn);
  color: var(--bone);
  font-size: clamp(11px, 0.84vw, 13px);
  font-weight: 700;
  letter-spacing: 0.13em;
  text-transform: uppercase;
  cursor: pointer;
  transform: translate(-50%, -50%);
  box-shadow: 0 10px 28px -12px rgba(14, 14, 14, 0.55);
  transition: transform 0.35s var(--ease), background-color 0.3s ease;
}

.film__start:hover {
  background: #241a1e;
  transform: translate(-50%, calc(-50% - 2px));
}

.film__start .film__icon { width: 15px; height: 15px; }

.film__start-time {
  padding-left: 12px;
  border-left: 1px solid rgba(239, 233, 225, 0.24);
  color: rgba(239, 233, 225, 0.72);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.04em;
}

.film.is-idle .film__start,
.film.is-ended .film__start { display: inline-flex; }

/* The poster is dimmed a step under the start button and at the end, so the
   button is the focal point without a scrim standing in for a design. */
.film.is-enhanced::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  background: rgba(14, 14, 14, 0.14);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.4s ease;
}
.film.is-idle::after,
.film.is-ended::after { opacity: 1; }

/* The bar. Sits on its own scrim because the films are light and dark by
   turns and bone controls have to read over both. */
.film__controls {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 2;
  display: none;
  align-items: center;
  gap: clamp(4px, 0.5vw, 8px);
  padding: 28px clamp(8px, 1vw, 14px) clamp(6px, 0.7vw, 10px);
  background: linear-gradient(to top, rgba(14, 14, 14, 0.82), rgba(14, 14, 14, 0.5) 45%, rgba(14, 14, 14, 0));
  color: var(--bone);
  transition: opacity 0.35s ease, transform 0.45s var(--ease);
}

.film.is-enhanced:not(.is-idle):not(.is-ended):not(.is-error) .film__controls { display: flex; }

.film.is-quiet .film__controls {
  opacity: 0;
  transform: translateY(8px);
  pointer-events: none;
}
.film.is-quiet,
.film.is-quiet .film__video { cursor: none; }

.film__btn {
  flex: none;
  display: grid;
  place-items: center;
  width: clamp(30px, 2.4vw, 36px);
  aspect-ratio: 1;
  padding: 0;
  border: 0;
  border-radius: 6px;
  background: transparent;
  color: var(--bone);
  cursor: pointer;
  transition: background-color 0.2s ease;
}

.film__btn:hover { background: rgba(239, 233, 225, 0.12); }

.film__icon {
  width: clamp(15px, 1.15vw, 18px);
  height: auto;
  display: block;
}

.film__btn:focus-visible,
.film__start:focus-visible,
.film__seek:focus-visible {
  outline: 2px solid var(--bone);
  outline-offset: 1px;
}

.film__time {
  flex: none;
  padding: 0 clamp(4px, 0.5vw, 8px);
  font-size: clamp(11px, 0.8vw, 12.5px);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.02em;
  white-space: nowrap;
}

.film__total,
.film__sep { color: rgba(239, 233, 225, 0.6); }
.film__sep { padding: 0 0.4em; }

/* The scrubber. A hairline of bone filled to the playhead, and the playhead
   itself is the page's amber square — the one module the page already uses
   for "this is where things are now". Square, per the Sharp-Type Rule. */
.film__seek {
  --p: 0%;
  flex: 1 1 auto;
  min-width: 40px;
  height: 22px;
  margin: 0 clamp(2px, 0.4vw, 6px);
  background: transparent;
  cursor: pointer;
  -webkit-appearance: none;
  appearance: none;
}

.film__seek::-webkit-slider-runnable-track {
  height: 2px;
  background: linear-gradient(to right, var(--bone) var(--p), rgba(239, 233, 225, 0.26) var(--p));
}
.film__seek::-moz-range-track {
  height: 2px;
  background: rgba(239, 233, 225, 0.26);
}
.film__seek::-moz-range-progress {
  height: 2px;
  background: var(--bone);
}

.film__seek::-webkit-slider-thumb {
  width: 10px;
  height: 10px;
  margin-top: -4px;
  border: 0;
  border-radius: 0;
  background: var(--amber);
  -webkit-appearance: none;
  appearance: none;
  transition: transform 0.2s var(--ease);
}
.film__seek::-moz-range-thumb {
  width: 10px;
  height: 10px;
  border: 0;
  border-radius: 0;
  background: var(--amber);
}
.film__seek:hover::-webkit-slider-thumb,
.film__seek:active::-webkit-slider-thumb { transform: scale(1.3); }

/* Buffering. A thin bone arc, only while the browser is actually waiting. */
.film__wait {
  position: absolute;
  left: 50%;
  top: 50%;
  z-index: 2;
  width: 34px;
  height: 34px;
  margin: -17px 0 0 -17px;
  border: 1.5px solid rgba(239, 233, 225, 0.22);
  border-top-color: var(--bone);
  border-radius: 50%;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.2s ease;
  animation: film-spin 0.9s linear infinite;
  animation-play-state: paused;
}
.film.is-waiting .film__wait {
  opacity: 1;
  animation-play-state: running;
}

@keyframes film-spin { to { transform: rotate(360deg); } }

.film__error {
  position: absolute;
  inset: auto 0 0;
  z-index: 3;
  display: none;
  margin: 0;
  padding: 12px 16px;
  background: rgba(14, 14, 14, 0.82);
  color: var(--bone);
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.13em;
  text-transform: uppercase;
}
.film.is-error .film__error { display: block; }

/* Fullscreen: the frame becomes the screen, the film letterboxes rather than
   crops. */
.film.is-full {
  aspect-ratio: auto;
  width: 100%;
  height: 100%;
  background: #000;
}
.film.is-full .film__video {
  object-fit: contain;
  background: #000;
}

/* Phones: the time drops before the scrubber gets too short to aim at. */
@media (max-width: 479px) {
  .film__time { display: none; }
  .film__start { padding: 11px 15px 11px 13px; gap: 10px; }
}

@media (prefers-reduced-motion: reduce) {
  .film__start,
  .film__controls { transition: none; }
  .film__wait { animation: none; border-color: rgba(239, 233, 225, 0.5); }
}

/* Foot */

.work__more {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: clamp(20px, 3vw, 48px);
  margin-top: clamp(34px, 5vh, 62px);
  padding: clamp(24px, 3.6vh, 44px) clamp(22px, 3vw, 52px);
  border: 1px solid var(--hairline);
}

.work__more-title {
  margin: 0;
  font-weight: 900;
  font-stretch: 62%;
  font-size: clamp(24px, 2.5vw, 42px);
  line-height: 0.92;
  letter-spacing: -0.008em;
  text-transform: uppercase;
}

.work__more-copy {
  margin: clamp(9px, 1.3vh, 15px) 0 0;
  color: var(--ink-soft);
  font-size: clamp(13px, 1vw, 16.5px);
  line-height: 1.55;
}

.work__more-btn { flex: none; }


/* ---------- skills ----------------------------------------------------------

   A grid of terms, and deliberately nothing else prose-shaped. The section this
   replaced put a paragraph under each heading; the paragraphs were the problem
   twice over — they read as written-by-a-machine, and nobody reads prose in a
   skills section anyway. What survives is a three-word label per group, which
   is a caption, not an argument.

   Six groups, and the ORDER is the one piece of editorial judgment left in it:
   01–04 are tools, 05–06 are not. Concepts you understand and things you are
   still learning do not belong in the middle of a run of package names, so they
   sit at the end where the change of kind is legible. 06 drops the chips for a
   led list for the same reason: "currently exploring" is a different claim from
   "I use this", and it should not be able to be misread as one.

   Two breaks with the page's system, both from the owner's comp and both
   recorded rather than left looking like drift:
     1. The terms are bordered chips. The One-Plane Rule said no boxes; About
        already broke it once for the values panel.
     2. The group numerals are SOLID, not the outlined numeral used by .marker
        and the project rows. Six outlined numerals on one screen read as a
        texture rather than as a count. (This also retires the translateZ(0)
        fringing workaround — a filled glyph has no transparent counter to
        fringe.)
   -------------------------------------------------------------------------- */

.skills {
  position: relative;
  z-index: 0;
  padding: clamp(64px, 11vh, 128px) var(--gutter) clamp(64px, 10vh, 120px);

  --group-size: clamp(25px, 2.22vw, 35px);
}

/* Head beside the grid rather than above it — the display pair reads as a left
   rail the six groups hang off. No max-width: the cap was capping the grid too,
   and the chips inside a group are content-sized, so a wider card spends the
   extra width on fewer wrapped rows rather than on padding. The gutter is doing
   the work the cap was there to do. */
.skills__body {
  display: grid;
  grid-template-columns: minmax(0, 0.56fr) minmax(0, 1fr);
  column-gap: clamp(30px, 4.2vw, 82px);
  /* Enough air under the marker rule that the first row of groups reads as
     starting, rather than as hanging off the rule. It is a section head, not a
     table header. */
  margin: clamp(40px, 6.4vh, 84px) 0 0;
  align-items: start;
}

.skills__heading {
  margin: 0;
  font-weight: 900;
  font-stretch: 62%;
  /* Shared with About and Projects — see --display-2. The constraint this
     section contributes is "BUILD WITH" holding one line in the left rail; at
     6.4vw it broke to three lines at 1440, and a display pair whose second line
     wraps stops being a pair. */
  font-size: var(--display-2);
  line-height: 0.86;
  letter-spacing: -0.012em;
  text-transform: uppercase;
}

.skills__grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  column-gap: clamp(26px, 3.4vw, 64px);
  /* Row gap runs well ahead of the column gap on purpose. The six groups read
     as three rows of two, and until the vertical space beat the horizontal the
     grid read as one field of chips with rules through it. */
  row-gap: clamp(52px, 8.4vh, 104px);
  margin: 0;
  padding: 0;
  list-style: none;
}

.skills__group { min-width: 0; }

/* Numeral, rule, arrow — the rule runs the full width of the card, so it
   measures the column rather than underlining anything. Same flex-spacer
   mechanism the group name used to carry; moved up here because the comp puts
   the rule above the name, where it separates groups instead of decorating one. */
.skills__gtop {
  display: flex;
  align-items: center;
  gap: clamp(10px, 1.2vw, 16px);
  margin-bottom: clamp(10px, 1.4vh, 16px);
}

.skills__num {
  font-size: var(--group-size);
  font-weight: 900;
  font-stretch: 62%;
  line-height: 0.8;
  letter-spacing: 0.01em;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}

.skills__grule {
  flex: 1 1 auto;
  height: 1px;
  background: var(--hairline);
}

/* Decorative, and marked as such: it points at the terms, it is not a link and
   must never be given an href. If a group ever earns a destination, promote it
   to an <a> and give it a real label — do not make this glyph clickable. */
.skills__garrow {
  flex: 0 0 auto;
  font-size: clamp(15px, 1.17vw, 18.5px);
  line-height: 1;
  color: var(--ink-soft);
}

.skills__gname {
  margin: 0 0 clamp(4px, 0.6vh, 7px);
  font-weight: 900;
  font-stretch: 62%;
  font-size: clamp(24px, 2.16vw, 36px);
  line-height: 1.06;
  /* Positive, unlike the display sizes. At 62% stretch the word space is nearly
     as narrow as a stem, and "ARCHITECTURE & SYSTEMS" was closing up around its
     ampersand into one word. */
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--ink);
}

/* The group captions ("Building the engine", "The next frontier") are gone:
   they restated the group name as a slogan. The name now sits straight on the
   chips, with the caption's old gap kept so the card's rhythm doesn't change. */
.skills__gname { margin-bottom: clamp(14px, 2vh, 20px); }

/* Chips wrap on their own content width. Not a grid and not `columns`: the
   terms are wildly uneven ("Zod" against "Row Level Security") and any fixed
   track spends the difference on air. */
.skills__list {
  display: flex;
  flex-wrap: wrap;
  gap: clamp(7px, 0.62vw, 10px);
  margin: 0;
  padding: 0;
  list-style: none;
}

.skills__list li {
  padding: 0.55em 0.95em;
  border: 1px solid var(--hairline);
  border-radius: 6px;
  color: var(--ink);
  font-size: clamp(14.5px, 1.1vw, 17.5px);
  line-height: 1.2;
  letter-spacing: -0.003em;
  white-space: nowrap;
}

/* 06 only. Led with an arrow and set in two plain columns, because these are
   not things in the toolkit yet and a chip identical to the ones above would
   claim they were. Grid rather than `columns` here: six short items, and
   reading across a pair is fine when there is no ordering to preserve. */
.skills__list--lead {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  column-gap: clamp(14px, 1.6vw, 26px);
  row-gap: clamp(9px, 1.3vh, 14px);
}

.skills__list--lead li {
  /* Flex so the arrow is its own item and a wrapped second line hangs under
     the TEXT rather than under the arrow. Two of these six run to two lines at
     this size, and indented back to the arrow they read as separate entries. */
  display: flex;
  gap: 0.6em;
  padding: 0;
  border: 0;
  border-radius: 0;
  white-space: normal;
  color: var(--ink-soft);
  font-size: clamp(15px, 1.15vw, 18px);
}

.skills__list--lead li::before {
  content: "\2192";
  flex: 0 0 auto;
  color: var(--ink);
}

@media (max-width: 1280px) {
  /* The head goes above the grid. Two things fail below this, and the wider of
     the two sets the breakpoint: the left rail gets too narrow to hold the
     display pair on two lines, and — first — the cards squeezed beside it get
     too narrow for their own terms. Above the grid the same card is roughly
     twice as wide and nothing is close to wrapping badly. */
  .skills__body {
    grid-template-columns: minmax(0, 1fr);
    /* Deliberately LARGER than .skills__grid's own row-gap, and that ordering
       is the point. At clamp(30px, 4.4vh, 54px) this resolved to 40px at
       1024x900 while the six groups were separated from each other by 76px and
       the marker above sat 58px clear - so the head was bound tighter to the
       grid than the grid's own rows were to one another, and the blurb read as
       a caption on the first card instead of as the section's standing head.

       Tracks .skills__grid's row-gap on the same vh axis, ~12% ahead of it, so
       the two move together and the ordering cannot silently invert. Change
       one and check the other. */
    row-gap: clamp(58px, 9.4vh, 116px);
  }

}

@media (max-width: 620px) {
  /* One group per row. The chips keep wrapping on their own, so nothing else
     has to change; 06's two columns collapse with them. */
  .skills__grid { grid-template-columns: minmax(0, 1fr); }
  .skills__list--lead { grid-template-columns: minmax(0, 1fr); }
}

/* ---------- the balancer ----------------------------------------------------

   What is left of the "Under load" section, which was removed. It kept the
   topology and the routing rule and lost everything that was explaining them:
   no heading, no marker, no labels, no counters. Full rationale in DESIGN.md.

   PLACEMENT is the owner's, from his own comp: the notch of negative space to
   the right of BUILD and above the long line. That notch is inside the display
   block's bounding box, so the only honest way to sit in it is absolute
   positioning against that block - which is what .contact__display exists for.

   The offsets below are PERCENTAGES OF THE DISPLAY BLOCK, not pixels, and that
   is what makes them hold across the range. Every line here is sized from the
   same clamp family in the same font at the same stretch, so BUILD's width as
   a fraction of the long line's width is very close to constant; a percentage
   tracks it, a pixel value does not.
   -------------------------------------------------------------------------- */

.contact__display {
  /* Shrink-to-fit, exactly like the h2 inside it, so this box IS the display
     block rather than the full section width. Positioning the diagram against
     the section instead would put it in a different place at every viewport. */
  display: table;
  position: relative;
}

.lbf {
  position: absolute;
  /* Anchored from the RIGHT, because the notch is bounded on its right by the
     long line's end and on its left by BUILD's - and BUILD's right edge is the
     one that moves, being the shorter word.

     TOP IS DERIVED, NOT PICKED. The owner asked for the drawing to be centred
     on BUILD, so it is centred on BUILD'S INK rather than on its line box -
     the line box carries a third of its height in air above the caps, and
     centring on that would sit the drawing visibly high. Range gives the
     advance box, so this is measured off the render by counting dark pixels
     per row and reading off the band where the count jumps. Note the band to
     take is the one with the most TOTAL ink, not the highest peak row: the
     outlined long line peaks higher than solid BUILD, because a row through it
     crosses twenty-one letters' worth of vertical stems.

     RE-DERIVED 2026-09-07 when --c-build came down to 0.84. At 1440 BUILD's
     ink now runs rows 119-306 of a display block 1188 x 431, so its centre is
     49.30% down the block. The drawing is 21% of the block's WIDTH and its
     viewBox is 240x200, so it is 0.21 x 1188 x (200/240) = 208px tall, which
     is 48.24% of the block's height:

         top = 49.30 - 48.24/2 = 25.18

     WIDTH CAME DOWN FROM 21% TO 17.8%, AND THE REASON IS THE LONG LINE. At
     21% the drawing is 208px tall against a block that is now 431px, and
     centring 208px on BUILD's ink puts its bottom edge at row 317 with the
     long line's ink starting at row 329 - twelve pixels of clearance, where
     there used to be about seventy-five. It also made the drawing TALLER THAN
     BUILD'S INK BAND (208 against 188), so it stopped reading as something
     sitting level inside BUILD's height and started reading as something
     overhanging it top and bottom. At 17.8% it is 176px, sits inside the
     188px band, and clears the long line by 28px.

         top = (212.5 - 176/2) / 431 = 28.9%

     RIGHT IS THE NOTCH SPLIT TOWARDS BUILD. A shorter BUILD ends at 51.77%
     across instead of 62.6%, so the notch went from 411px wide to 539 and the
     old 13.5% left the drawing hugging the right edge of a gap it no longer
     filled. The 211px drawing takes ~125px of the slack on its left and the
     rest on its right - biased towards BUILD, which is the direction the owner
     walked it over two rounds of review, and it puts the leftover air above
     the solid "SCALES." rather than above the outlined words.

     Re-derive ALL THREE if any of the type sizes move. The drawing has been
     walked in from 6.5%/12% over two rounds of owner review; the candle's
     rendered weight is held constant across this resize by .lbf__req, which
     is the one number in here that is his rather than derived. */
  /* Walked one more step left, to 23%, on owner review: with the notch this
     wide the derived centre still read as sitting off to the right. This one
     is his eye, not a derivation. */
  right: 23%;
  top: 28.9%;
  width: 17.8%;
  height: auto;
}

.lbf__sq { fill: var(--amber); }

/* Hairline, and it stays hairline - the load is not carried by this. Without
   JS these four lines plus the squares are the whole drawing, and it is a
   correct static architecture diagram. */
.lbf__wire {
  fill: none;
  stroke: var(--hairline);
  stroke-width: 1.5;
  vector-effect: non-scaling-stroke;
}

/* Never painted. These exist only to be measured and cloned - see the note in
   the markup on why the motion guides are separate from the visible wires. */
.lbf__route { fill: none; stroke: none; }

/* THE REQUEST. A short heavy run of the wire, slid along it by dashoffset.

   WIDTH: 4 user units, ~4.2px at the drawing's desktop scale against the wire's
   1.5px. It was briefly 9 (too heavy, read as a black slug) and then 5.3; the
   owner asked for thinner again. The floor is the wire itself - below about 3
   units the candle stops reading as a thickening of the line and starts reading
   as a second, darker line laid over it.

   IT MUST NOT TAKE non-scaling-stroke, unlike the wires, and this is not a
   stylistic difference - it was tried and it broke the animation. Under
   non-scaling-stroke the browser resolves stroke-dasharray and -dashoffset in
   SCREEN PIXELS as well as the width, while the geometry the candle has to line
   up with (the balancer box it hides inside) stays in user units. Measured at
   390: an offset of -100 put the candle 100px down the path instead of 100
   units, so it sat well below where the maths said it was. Everything the
   candle is positioned by has to live in one coordinate space, and that space
   is user units.

   The cost is that the weight difference is exact at desktop and narrows at
   390, because the whole drawing is 0.70 scale there. That is the right trade:
   the drawing shrinking as a whole is correct, a candle that keeps its absolute
   weight while its wire shrinks is not.

   COLOUR IS --hairline-load, the wire's own colour two steps down, and this is
   its only use on the page. Both alternatives were built and rejected: amber
   has nothing to land on when it arrives into an amber square, and ink - the
   first version - read as a black slug dropped on a pale drawing. See the token
   for the reasoning.

   Butt caps, not round: a request is a candle, not a capsule, and a rounded run
   reads as a slug sliding down a track. */
.lbf__req {
  fill: none;
  stroke: var(--hairline-load);
  /* 3.54 USER UNITS IS 3 UNITS OF RENDERED WEIGHT, and the number moved only
     to keep the render identical. The owner tuned this by eye over three
     rounds - 9, 5.3, 4, 3 - and 3 units was the settled answer AT A DRAWING
     249px WIDE, where it rendered 3.12px against the wire's fixed 1.5px. The
     drawing came down to 211px on 2026-09-07, and because this path
     deliberately does NOT take non-scaling-stroke (see above), 3 units would
     have rendered 2.64px and quietly undone a round of his tuning:

         3 x (249.5 / 240) = 3.12px      3.54 x (211.5 / 240) = 3.12px

     So the rendered weight and its 2.08:1 ratio to the wire are unchanged.
     IF THE DRAWING'S WIDTH MOVES AGAIN, rescale this with it rather than
     re-picking it; the value that is his is the 3.12px, not the 3.54. */
  stroke-width: 3.54;
  stroke-linecap: butt;
}

/* Below the breakpoint where the contact row stacks, the notch beside BUILD
   closes up - the display block narrows faster than the words inside it, and
   an absolutely positioned diagram would sit on top of the type. It returns to
   the flow underneath the headline instead, which is the one place it can go
   without competing with anything. */
@media (max-width: 860px) {
  .lbf {
    position: static;
    width: min(190px, 52%);
    margin: clamp(22px, 4vh, 40px) 0 0;
  }
}

/* ---------- contact ----------------------------------------------------------

   The terminus. Same bone ground and same 62% display cut as the rest of the
   page - both of those were tried the other way and reverted by the owner, and
   the reasons are worth keeping so neither gets re-proposed:

   INVERTED, ON A DARK PLANE. Built and rejected. It was strong on its own and
   wrong in the page: at the time the marquee's ribbon was the only place the
   reader's ground changed, and a second, larger dark plane at the end spent
   that device twice. THE MARQUEE WAS REMOVED IN 2026-09, so that particular
   argument no longer holds — the page is now one plane throughout, and a dark
   closing section would be the only change of ground rather than the second.
   The decision stands as the owner's, but it stands on his judgement now and
   not on this reasoning; anyone reopening it should reopen it with him. (If it
   ever returns: #262626 NEUTRAL. The old ribbon was #2F2C29, warm by design —
   right on a 120px strip, a visible yellow cast across a full viewport.)

   font-stretch: 125%. Built and rejected. It matched the reference comp's
   proportions, and it read as a different typeface bolted to the end of the
   site - every other display element here is the 62% cut.

   THE LINE COUNT IS THE FIX, and it is worth understanding before anyone
   "restores" the comp's four lines. At 62% a five-character word only
   out-widths a nine-character one at roughly twice the size, and that ratio
   pins a four-line block's aspect near 1.24 at ANY scale - so on this cut
   "BUILD wider than SOMETHING" and "wide rather than tall" cannot both hold.
   Folding SOMETHING and THAT SCALES into one 21-character line dissolves the
   conflict rather than trading it off: the long line spans the measure at a
   modest size, the block goes to ~2.1:1, and BUILD is the largest thing on it
   with nothing wider underneath to compete. Same words, same sentence, same
   reading order. Four lines were built first and rejected on sight.

   Three decisions carried from the owner's comp, all recorded in PRODUCT.md:

   1. NO PORTRAIT. The comp had the figure standing in the type as the hero
      does. Repeating the occlusion turns a signature into a mannerism, and it
      costs BUILD its unbroken counter. The hero owns the occlusion; this
      section owns the scale.
   2. THE AMBER SQUARE IS THE FULL STOP - the wordmark's own tittle at sentence
      scale, so the page opens on that square and closes on it. This section
      spends the accent exactly twice, the stop and the email's bullet. A third
      would break the pair's rhyme, which is the only reason either reads as
      deliberate rather than as decoration.
   3. THE FOOTER IS INSIDE THE SECTION. Two full closing gestures back to back
      dilute each other, so it is a fine rule within the section that already
      ends the page. It is also the only place the year appears.

   -------------------------------------------------------------------------- */

.contact {
  position: relative;
  z-index: 0;
  color: var(--ink);
  /* The bottom padding grew when the foot was removed. The foot used to sit
     between the address rule and the end of the document and gave the page
     somewhere to stop; without it 29px of padding put the last rule almost on
     the edge of the viewport and the page ended mid-gesture. */
  padding: clamp(56px, 9vh, 104px) var(--gutter) clamp(52px, 7.4vh, 96px);

  /* Measured off the rendered pixels at 1440, not derived - see the note by
     .cline--lets. Ink began at 5px (LET'S), 17px (BUILD) and 2px (the long
     line); B is the one glyph genuinely out of line, as it was on every
     earlier cut of this section. Aligned to the long line, since that is the
     one locking the block's width to the rules. In em, so they hold at every
     viewport - re-measure if the words or the sizes change. */
  --sb-lets:  -0.022em;
  --sb-build: -0.0348em;
  --sb-long:   0;

  /* BUILD IS AT 0.84 OF THE VALUE IT HELD UNTIL 2026-09-07, and the reduction
     paid for something specific rather than being a matter of taste. The
     section ran 344px of display type straight into a 10-17px band holding the
     email, the profile links and the foot - a 21:1 drop with nothing in
     between, so the eye landed on BUILD, found no second stop and left. The
     ladder now reads 289 / 129 / 81 / 50 / 17 / 11 and the address is the
     second-largest thing in the section.

     BUILD IS STILL UNAMBIGUOUSLY THE LARGEST THING HERE (2.24x the long line,
     against 2.67x before) and every horizontal relationship the layout rests
     on is unchanged: the long line still spans the measure and still sets the
     block's width, so the block is the same width and only its height moved.
     WHAT DID MOVE IS THE NOTCH - a smaller BUILD ends further left, so the
     balancer's `right` and `top` were both re-derived against the new render.
     Re-derive them again if this number changes. */
  --c-build: min(20.1vw, 35.6vh);
  --c-long:  min(8.95vw, 15.9vh);
  --c-lets:  min(5.6vw, 9.9vh);
}

/* The shared marker, re-tuned for the plane rather than reimplemented. Only
   the three colour declarations change; the geometry stays with .marker so the
   numbering can still never drift onto a second scale. */
/* No overrides needed now the ground is bone again - .marker is already
   correct on this plane, which is the point of it being shared. */

.contact__headline {
  /* Its own column, not the page gutter. See the note above: at 62% stretch
     these four words cannot reach the full measure at any sane height, so the
     block is sized to its own content and the plane holds it rather than the
     block trying to hold the plane. */
  display: table;
  margin: clamp(26px, 4.6vh, 60px) 0 0;
  font-weight: 900;
  font-stretch: 62%;
  /* Tighter than the 0.85 the rest of the page sets. Every line here is all
     caps with no descender, so the line box is mostly air below the baseline,
     and that air is what was making the block tall. */
  line-height: 0.82;
  letter-spacing: -0.04em;
  text-transform: uppercase;
}

.cline { display: block; position: relative; }

/* Optical left edge, and READ THIS BEFORE TOUCHING IT.

   Four lines at four different sizes share one left edge. A glyph's left
   sidebearing is a constant fraction of its em, so at four sizes it resolves
   to four different PIXEL offsets, and the edge rags in a way it never does in
   About or Skills - there, the pair is set at one size and the two offsets are
   equal. That much is real and specific to this section.

   What is NOT settled is the size of it, and two measuring techniques have
   already lied about it here:
     - Range.getBoundingClientRect returns the ADVANCE box, so every line
       reports the same left and the rag is invisible to it. Same family of
       mistake as SVG getBBox in the band.
     - Canvas measureText will silently ignore `ctx.fontStretch = '62%'`, and
       an invalid font shorthand resets it to 10px sans-serif. Both failures
       are quiet and both produce confident, wrong numbers. Validate any canvas
       measurement against the page's own advance width before believing it.

   The hook is here and the correction is OFF: `left` and not margin, because
   these lines sit in a shrink-to-fit table where a negative margin widens the
   box and drags the right edge with it. Set --sb-* on .contact in em to turn
   it on, but measure it off the rendered pixels first. */
/* LET'S is compressed vertically, not resized. Archivo at 62% sets tall caps
   relative to their width, and the word read as too tall even after its size
   came down - the complaint was about the letterform, so the fix has to be too.
   scaleY keeps every horizontal dimension exactly as measured, so the edge
   alignment and the block's width survive untouched.

   THE NEGATIVE MARGIN IS NOT OPTIONAL AND IT IS DERIVED, not eyeballed.
   Transforms do not affect layout, so without it the line keeps its full
   untransformed height and the composition opens back up by exactly what was
   just squeezed out. The value is half the lost height at each end:

       margin-block = -(1 - scaleY) x line-height / 2
                    = -(1 - 0.88) x 0.82 / 2  =  -0.0492em

   Change the scale and recompute from that formula. The two are one adjustment
   in two declarations; moving either alone opens or closes a gap. */
.cline--lets {
  font-size: var(--c-lets);
  left: var(--sb-lets, 0);
  transform: scaleY(0.88);
  transform-origin: center;
  margin-block: -0.0492em;
}
/* BUILD's squash has been set THREE times and the current value is the settled
   one - 0.93, arrived at from both directions. It was built at 0.82, the owner
   asked for the height back (0.82 -> 1), then asked for "just a little bit"
   taken off again, "not as much as before". 0.93 is that: roughly a third of
   the original compression.

   Same formula as .cline--lets, and the paired margin is NOT optional:

       margin-block = -(1 - scaleY) x line-height / 2
                    = -(1 - 0.93) x 0.82 / 2  =  -0.0287em

   Recompute it from the scale if the scale moves again. Transforms do not
   affect layout, so without the margin the line keeps its full untransformed
   height and the block opens back up by exactly what was squeezed out. */
.cline--build {
  font-size: var(--c-build);
  left: var(--sb-build, 0);
  transform: scaleY(0.93);
  transform-origin: center;
  margin-block: -0.0287em;
}

/* The outlined line, and the pair's whole device: weight against contour,
   exactly as .dline does it - this is the same device, same ink stroke.

   It is also the line that sets the block's width, and that is the load-bearing
   part of this layout rather than a stylistic preference. Twenty-one characters
   at the condensed cut span the measure at a modest size, which is exactly what
   the four-line version could not do: there the widest line was SOMETHING at
   nine characters, and reaching the measure with it meant a size that dragged
   the whole block tall. IF THE COPY EVER SHORTENS, re-derive every size below
   rather than assuming this line still spans. */
.cline--long {
  font-size: var(--c-long);
  left: var(--sb-long, 0);
  /* This line is DESIGNED as one line, and it must be defended as one. Sized
     to fill the measure, it filled it exactly - leaving no room for the amber
     full stop, which wrapped onto a line of its own and added a whole empty
     line box to the block (215px where one line is 108px). The size above now
     carries ~19px of headroom for the stop, and this is the belt to that
     braces: an orphaned period is the failure mode, and it is silent. */
  white-space: nowrap;
  color: transparent;
  -webkit-text-stroke: max(1px, 0.075vw) var(--ink);
  /* LOOSER than the solid lines, not tighter. A stroke is drawn centred on the
     contour, so half of it grows outward into the sidebearing and an outlined
     word sets visually tighter than a solid one at identical tracking. */
  letter-spacing: -0.028em;
  /* Compositing forces grayscale antialiasing; subpixel AA fringes the edge of
     a transparent-filled glyph. Windows ignores -webkit-font-smoothing. */
  transform: translateZ(0);
}

/* SCALES — the letters fill from the baseline up, one after another, as the
   section arrives. The RESTING state is a plain solid fill; the staggered climb
   is an entrance, not a look.

   A static version of this shipped first, with the six letters held at
   different permanent heights as a "growth curve". It was rejected: frozen
   mid-fill it reads as six letters that failed to render rather than as a word
   doing something. The fill only means anything while it is moving.

   MECHANISM: background-size, not a gradient colour-stop. Gradient stop
   positions do not interpolate (short of a registered @property), whereas
   background-size does, everywhere. A flat ink "gradient" is anchored to the
   bottom of each glyph at 100% 0% and grown to 100% 100%; background-clip:text
   confines it to the letterform. GSAP drives it and staggers the six.

   The resting state below is the FILLED one, and that is deliberate: the start
   state is set in script.js immediately before the tween that undoes it, so the
   only code that can leave this word unfilled is the code already committed to
   filling it. No JS, or reduced motion, and SCALES is simply solid.

   DEGRADATION: the fill lives inside @supports. Where background-clip:text is
   unavailable the letters inherit the line's transparent fill and stay outlined
   like the rest of it. What must never happen is the fill applying WITHOUT the
   clip, which paints solid rectangles across the word. */
.scale > span { font-style: normal; }

@supports ((-webkit-background-clip: text) or (background-clip: text)) {
  .scale > span {
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
    background-image: linear-gradient(var(--ink), var(--ink));
    background-repeat: no-repeat;
    /* Anchored to the bottom so growth reads as filling up, not as sliding. */
    background-position: 0 100%;
    background-size: 150% 100%;

    /* THE PADDING/MARGIN PAIR IS A BUG FIX, NOT SPACING. Do not "clean it up".

       These letters carry letter-spacing: -0.028em (-3.6px at 1440), and CSS
       applies letter spacing AFTER each character, so each span's box ends 3.6px
       before its own glyph's ink does. That 3.6px strip of ink falls inside the
       NEXT span's box, where it is not that span's text either - so with
       background-clip: text neither span paints it, and a thin unfilled sliver
       runs down the right edge of every letter.

       background-size cannot reach it: clip:text still paints only within the
       element's own box, so the box is what has to grow. padding-right widens
       the paint area and the equal negative margin-right takes the width back
       out of layout, so the advance, the word's width and the block's right
       edge are all unchanged. 0.05em against the 0.028em overhang, for rounding
       headroom. Move one of the two and the word's spacing breaks. */
    padding-right: 0.05em;
    margin-right: -0.05em;
  }
}

/* The full stop, and the wordmark's tittle at sentence scale. vertical-align
   baseline puts an inline-block's bottom margin edge on the baseline, which is
   where a period sits. */
.cline__stop {
  display: inline-block;
  width: 0.19em;
  height: 0.19em;
  margin-left: 0.08em;
  background: var(--amber);
  vertical-align: baseline;
}

/* ---- the CTA row ----

   THE ADDRESS IS THE SECOND-LARGEST THING IN THE SECTION, and that is the
   whole point of this row. It previously sat at 16.5px in the MIDDLE of a
   space-between row, flanked by an 11px label and two 13px profile links, with
   the foot's 10px directly beneath it - four ranks inside a 1.6:1 range, which
   is no hierarchy at all. `space-between` on three items also gave the label,
   the control and the two secondary links equal standing. Two items now: the
   control left, the routes right. */

.contact__row {
  display: flex;
  /* Baseline, not centre. At a 3:1 size difference centring floats the profile
     links in the middle of the address's box and they read as unaligned; on
     the baseline they sit on the same line the address is set on. */
  align-items: baseline;
  justify-content: space-between;
  gap: clamp(16px, 2.4vw, 40px);
  margin-top: clamp(30px, 5.2vh, 64px);
  padding-top: clamp(20px, 2.8vh, 34px);
  border-top: 1px solid var(--hairline);
}

/* THE ONE CONTROL ON THE PAGE, sized like it.

   The section spends size on the headline, motion on the balancer and amber on
   the full stop. Before this the email had NO attention device: it was body
   copy with a hairline under it, in the same colour and the same 1px weight as
   the two structural rules around it, so its only affordance was
   indistinguishable from the furniture.

   NORMAL STRETCH, NOT THE 62% DISPLAY CUT. The rhyme was tempting and it was
   tried: an address is lowercase, carries an @ and two dots, and at 62% the
   condensed forms close up around them - it reads as a decorative object
   rather than as something to type into a mail client. The display cut belongs
   to the sentence; the control is set in the page's own voice, just loudly. */
.contact__mail {
  display: inline-flex;
  align-items: baseline;
  gap: 0.42em;
  color: var(--ink);
  font-size: clamp(24px, 3.45vw, 54px);
  font-weight: 600;
  letter-spacing: -0.022em;
  text-decoration: none;
}

/* THE RULE IS A BORDER ON THE ANCHOR, NOT text-decoration ON THE TEXT, and
   that is the whole reason it can run under the square. text-decoration is not
   painted through an inline-block descendant, so with the underline on the
   text the rule stopped dead at the address and the square floated above open
   ground. A border on the flex box spans text, gap and square as one.

   The padding is what clears the descenders of "gmail": at 3px the rule struck
   through the tail of every g. */
.contact__mail {
  padding-bottom: 0.3em;
  border-bottom: max(1px, 0.045em) solid var(--hairline);
  transition: border-color 0.28s var(--ease);
}

.contact__mail:hover,
.contact__mail:focus-visible { border-bottom-color: var(--ink); }

/* THE SQUARE NOW DOES A JOB. It used to sit to the LEFT of the address as a
   bare amber chip - the wordmark's mark, but decoration, and it read as having
   been put there because the page had a square in it. It has moved to the
   right of the address and carries the "leaves this page" arrow, so the accent
   marks the control's exit rather than just labelling it. It is still the
   page's mark and it is still the section's second and last use of amber.

   It is bigger than the old 0.5em because it now has to hold a glyph: 0.5em
   with a 1.9-weight arrow inside is a smudge. The rule beneath it comes from
   .contact__mail's border, which is why that border is on the anchor. */
.contact__sq {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 0.72em;
  height: 0.72em;
  flex: none;
  align-self: baseline;
  /* CENTRED ON THE ADDRESS'S INK, found by scanning the render rather than
     reasoned off the em box. The address is all lowercase, so there are no caps
     to centre on: the ink runs from the ascenders of d and l to the descender
     of g, and its centre and the x-height band's centre agree to a pixel. The
     old value centred on caps the address does not contain and sat the chip 6px
     low at 49.7px. Baseline alignment plus a shift, not align-items:center - a
     flex container takes its baseline from its first item, which is what hung
     the profile row below it wrong once already. */
  transform: translateY(-0.06em);
  background: var(--amber);
  /* Bone, not white: the arrow should read as the ground showing through the
     chip, which is the same logic as --glyph-veil in the display type. */
  color: var(--bone);
}

.contact__sq svg { width: 62%; height: 62%; display: block; }

/* The secondary routes. They are CONTROLS, not footer furniture, so they come
   up with the address rather than staying in the fine print - but they stay
   clearly subordinate to it at roughly a third of its size. */
.contact__profiles {
  display: flex;
  align-items: baseline;
  gap: clamp(18px, 2.2vw, 36px);
}

/* BASELINE, NOT CENTRE, AND THE GLYPH IS WHY. A flex container takes its
   baseline from its FIRST flex item, and the first item here is the brand SVG.
   A replaced element's baseline is its bottom margin edge, so with
   align-items:center the anchor reported a baseline 9.3px above its own text's
   - and .contact__row, which is baseline-aligned, then hung both links that
   much high against the address. Measured, not guessed: a zero-width inline
   probe appended to each element returns its true baseline, where
   getBoundingClientRect returns a box that says nothing about where the text
   sits inside it. Baseline-aligning the glyph makes the anchor's baseline the
   TEXT's baseline, which is the one that has to line up. */
.contact__profile {
  display: inline-flex;
  align-items: baseline;
  gap: 0.5em;
  color: var(--ink);
  font-size: clamp(13px, 1.15vw, 18px);
  font-weight: 500;
  text-decoration: none;
  /* Was 0.82. The opacity was doing the demotion when size should have been;
     at 0.82 on bone these were the faintest text in the section as well as
     nearly the smallest. Size separates them now, so they can be solid. */
  opacity: 1;
  white-space: nowrap;
  transition: color 0.28s var(--ease);
}

.contact__profile:hover { color: var(--hairline-load); }

/* THE DIVIDER BETWEEN THEM IS GONE, along with the two trailing arrows. That
   strip carried sixteen separate marks in the smallest type on the page - the
   densest thing on the site at the size with the least room for it. The gap
   separates the two links perfectly well, and a third hairline within 60px of
   the row rule read as fine print rather than as structure. */

/* The nudge is optical and it is paid for by the line above. Baseline-aligned,
   the glyph's BOTTOM sits on the baseline, so a 1.12em icon stands 1.12em above
   a line whose caps are ~0.72em and reads far too high. 0.2em down puts its
   centre on the caps' centre. It is a transform rather than a margin or a
   position so it cannot feed back into the baseline it was just aligned to. */
.contact__glyph {
  width: 1.12em;
  height: 1.12em;
  flex: none;
  transform: translateY(0.2em);
}


@media (max-width: 860px) {
  .contact__row {
    flex-direction: column;
    align-items: flex-start;
    gap: clamp(20px, 3.4vh, 30px);
  }
  /* The address is the longest unbreakable string in the section and it is now
     set large. Below ~470px the 24px floor of its clamp still overruns the
     gutter, so the floor gives way here rather than letting one string push
     the whole page wide. */
  .contact__mail { font-size: clamp(17px, 5.4vw, 30px); }

  /* THE LONG LINE NEEDS LOOSER TRACKING SMALL, NOT THE SAME TRACKING SCALED.
     -0.028em is right at 129px and reads as letters touching at 35px: the
     stroke is a MINIMUM of 1px whatever the size, so as the type shrinks the
     contour eats a growing share of the gap between glyphs, and an outlined
     face sets visually tighter than a solid one at identical tracking to begin
     with. Opening it up widens a line that is sized to span the measure
     exactly, so --c-long pays for it: 21 characters gaining 0.02em each is
     ~0.42em on a ~9.6em line, which is the 4% the size comes down by. Move the
     two together or the line stops spanning. */
  .cline--long { letter-spacing: 0.004em; }
  .contact { --c-long: min(8.45vw, 15.05vh); }
}

/* ---------- case study ----------
   The long form behind one Work row, and the first page on this site that is
   read rather than skimmed. That changes two things and nothing else:

     1. There is a reading measure. The index page has no running prose longer
        than three lines and sets everything to the full gutter; a case study
        that did the same would run 140 characters a line on a wide monitor.
        --cs-measure caps the prose column and the page centres inside it.
     2. Display type becomes a signpost instead of the composition. The h1 is
        still the solid/outline pair, but at roughly half the hero's scale, and
        the section h2s are smaller again — they mark position in a document,
        they do not have to carry a viewport on their own.

   Everything else is the index page's vocabulary unchanged: the .marker rule,
   the .dline pair, the amber square, the .shot chrome, the same two neutrals. */

.page--cs {
  /* The masthead is position:absolute against the hero on the index page. Here
     there is no hero to sit inside, so the document needs to start below it. */
  padding-top: clamp(74px, 11vh, 118px);
}

.cs {
  /* One column, centred, with the gutter still doing the outer margin. The
     content max is wider than the prose max on purpose — shots and the numbers
     strip use the full column, prose does not. */
  --cs-measure: 68ch;
  --cs-max: 1180px;
  display: block;
  padding: 0 var(--gutter) clamp(64px, 10vh, 120px);
}

.cs__block {
  max-width: var(--cs-max);
  margin: 0 auto;
  padding: clamp(46px, 7vh, 88px) 0 0;
}

.cs__block--open { padding-top: clamp(10px, 2vh, 26px); }
.cs__block--tight { padding-top: clamp(30px, 4.6vh, 54px); }

/* Back to the list. Sits under the marker rather than in the masthead because
   the masthead is the site's navigation and this is the document's — mixing the
   two is how a reader ends up with two competing "back"s. */
.cs__back {
  display: inline-flex;
  align-items: center;
  gap: clamp(10px, 1.1vw, 16px);
  margin-top: clamp(18px, 2.6vh, 30px);
  color: var(--ink-soft);
  font-size: clamp(10px, 0.78vw, 13px);
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  text-decoration: none;
  transition: color 0.3s ease;
}

.cs__back:hover { color: var(--ink); }

.cs__back-arrow {
  width: clamp(20px, 1.7vw, 30px);
  height: auto;
  flex: none;
  transition: transform 0.35s var(--ease);
}

.cs__back:hover .cs__back-arrow { transform: translateX(-4px); }

/* Title and lede on one row. `align-items: end` puts the lede's last line on
   the title's baseline rather than centring it against a two-line display
   block, which is the same relationship .work__head sets up. */
.cs__opening {
  display: grid;
  grid-template-columns: minmax(0, auto) minmax(0, 1fr);
  align-items: end;
  column-gap: clamp(28px, 4.4vw, 84px);
}

.cs__title {
  margin: clamp(16px, 2.4vh, 30px) 0 0;
  font-weight: 900;
  font-stretch: 62%;
  font-size: clamp(46px, 8vw, 128px);
  line-height: 0.86;
  /* Looser than the Work heading's -0.012em, and the outline line is why. A
     1px stroke adds a pixel to BOTH sides of every glyph, so an outlined line
     needs more tracking than a solid one at the same size to read as separate
     letters. At 128px and -0.012em the strokes in "TEXTILES" met and the word
     turned into a lattice. */
  letter-spacing: -0.004em;
  text-transform: uppercase;
}

.cs__lede {
  margin: 0 0 clamp(4px, 1.4vh, 16px);
  max-width: 40ch;
  color: var(--ink-soft);
  font-size: clamp(16px, 1.32vw, 23px);
  line-height: 1.5;
}

/* The facts table. Four hairline-topped cells on one row, which is the same
   ruled-row logic as .marker rather than a card grid — no borders on the sides,
   no fill, nothing that reads as a panel. */
.cs__facts {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: clamp(16px, 2.4vw, 40px);
  margin: clamp(30px, 4.6vh, 58px) 0 0;
}

.cs__fact {
  padding-top: clamp(10px, 1.4vh, 16px);
  border-top: 1px solid var(--hairline);
}

.cs__fact dt {
  font-size: clamp(9.5px, 0.72vw, 12px);
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.cs__fact dd {
  margin: clamp(7px, 1vh, 11px) 0 0;
  color: var(--ink);
  font-size: clamp(13px, 1vw, 17px);
  line-height: 1.42;
}

/* The lead shot */

.cs__lead {
  max-width: var(--cs-max);
  margin: clamp(38px, 6vh, 76px) auto 0;
}

.cs__caption {
  margin-top: clamp(11px, 1.6vh, 17px);
  color: var(--ink-soft);
  font-size: clamp(11.5px, 0.86vw, 14px);
  line-height: 1.5;
}

/* Section body — the h2 holds a left rail, the argument runs beside it. On one
   column the h2 simply sits above, which is the correct collapse: it is a
   heading, and a heading that has to stay beside its text is a caption. */
.cs__split {
  display: grid;
  grid-template-columns: minmax(0, 0.72fr) minmax(0, 1.28fr);
  column-gap: clamp(28px, 4.4vw, 84px);
  margin-top: clamp(24px, 3.6vh, 46px);
}

.cs__h2 {
  margin: 0;
  font-weight: 900;
  font-stretch: 62%;
  font-size: clamp(28px, 3.2vw, 54px);
  line-height: 0.9;
  letter-spacing: -0.008em;
  text-transform: uppercase;
}

.cs__prose { max-width: var(--cs-measure); }

.cs__prose p {
  margin: 0 0 1.05em;
  color: var(--ink-soft);
  font-size: clamp(15px, 1.14vw, 19px);
  line-height: 1.62;
}

.cs__prose p:last-child { margin-bottom: 0; }

/* Constraint list. The amber square again, as a marker rather than a bullet —
   the page has no round bullet anywhere and introducing one here would be a
   second list vocabulary for no reason. */
.cs__list {
  margin: clamp(20px, 3vh, 32px) 0 0;
  padding: 0;
  list-style: none;
}

.cs__list li {
  position: relative;
  padding: 0 0 0 clamp(20px, 1.8vw, 30px);
  margin-bottom: clamp(11px, 1.6vh, 17px);
  color: var(--ink-soft);
  font-size: clamp(14px, 1.04vw, 17px);
  line-height: 1.55;
}

.cs__list li:last-child { margin-bottom: 0; }

.cs__list li::before {
  content: "";
  position: absolute;
  left: 0;
  /* Optically centred on the first line rather than on the box: at 1.55 line
     height a top-aligned square sits visibly above the cap line. */
  top: 0.58em;
  width: 0.42em;
  height: 0.42em;
  background: var(--amber);
}

/* Numbers */

.cs__stats {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: clamp(18px, 3vw, 52px);
  margin: 0;
  padding-top: clamp(22px, 3.2vh, 36px);
  border-top: 1px solid var(--hairline);
}

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

.cs__figure {
  font-size: clamp(34px, 4.4vw, 74px);
  font-weight: 900;
  font-stretch: 62%;
  line-height: 0.82;
  letter-spacing: -0.035em;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}

.cs__statlabel {
  margin: 0.7em 0 0;
  font-size: clamp(9.5px, 0.72vw, 12px);
  font-weight: 700;
  line-height: 1.42;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

/* Build steps — the Work row's rail, reused. Numeral in a fixed column with a
   rule dropping out of it; body in the second column. The token pair is
   redeclared locally rather than inherited from .work, because these numerals
   are set smaller and a shared token would tie a document's step numbering to a
   poster's project numbering. */
.cs__steps {
  --step-size: clamp(20px, 1.9vw, 32px);
  --step-col: clamp(32px, 3vw, 52px);
  margin: 0;
  padding: 0;
  list-style: none;
  max-width: var(--cs-measure);
}

.cs__step {
  position: relative;
  display: grid;
  grid-template-columns: var(--step-col) minmax(0, 1fr);
  column-gap: clamp(10px, 1.2vw, 20px);
  padding-bottom: clamp(26px, 4vh, 44px);
}

.cs__step:last-child { padding-bottom: 0; }

.cs__step::before {
  content: "";
  position: absolute;
  left: calc(var(--step-col) / 2);
  top: calc(var(--step-size) * 0.8 + 12px);
  bottom: clamp(4px, 0.8vh, 10px);
  width: 1px;
  background: var(--hairline);
}

/* The last step's rule would run to nothing — there is no following item for it
   to connect to, so it stops at the numeral instead of trailing off. */
.cs__step:last-child::before { display: none; }

.cs__step-num {
  /* Centred in its column because the rule below is drawn at the column's
     centre. Left-aligned, the numeral sat left of the line it hangs from. The
     negative right margin cancels the letter-spacing trailing the last digit,
     which would otherwise nudge the ink half a space left of centre. */
  margin: 0 -0.02em 0 0;
  text-align: center;
  font-size: var(--step-size);
  font-weight: 900;
  font-stretch: 62%;
  line-height: 0.8;
  letter-spacing: 0.02em;
  font-variant-numeric: tabular-nums;
  color: transparent;
  -webkit-text-stroke: 1px var(--hairline);
}

.cs__step-title {
  margin: 0;
  font-size: clamp(16px, 1.28vw, 22px);
  font-weight: 600;
  font-stretch: 92%;
  line-height: 1.28;
  letter-spacing: -0.012em;
  color: var(--ink);
}

.cs__step-body p {
  margin: clamp(9px, 1.3vh, 14px) 0 0;
  color: var(--ink-soft);
  font-size: clamp(14px, 1.04vw, 17px);
  line-height: 1.6;
}

/* Detail figure */

.cs__detail { margin: clamp(26px, 4vh, 50px) 0 0; }

/* A crop, not a second screenshot. The chrome bar is dropped because a cropped
   region is not a window, and keeping it would claim this is a separate page. */
/* The same {rounded.screen} as the full chrome. A crop having its own slightly
   smaller radius was an arbitrary distinction — both are the same material. */
.shot--crop { border-radius: 10px; }

.shot--crop img {
  /* Restated for the same reason as .shot img above — the crop carries the same
     width/height attributes and would otherwise ignore this ratio too. */
  height: auto;
  /* 21/9 against a 1490x866 source leaves ~180px of vertical travel at this
     column width, and object-position pushes all of it to the foot — so this
     frame lands on the plate row that the lead shot cuts off, rather than
     repeating the masthead the reader has already seen. */
  aspect-ratio: 21 / 9;
  object-position: 50% 100%;
}

/* A code sample inside a case study's prose column — the blog's block, given
   the prose's own vertical rhythm. */
.bl__pre.cs__code { margin: clamp(20px, 3vh, 32px) 0 0; }

/* The outcome's way off the page: live site, source, write-up. The same
   underlined CTA the Work rows use, wrapped as a row. */
.cs__links {
  display: flex;
  flex-wrap: wrap;
  gap: 0 clamp(22px, 2.6vw, 44px);
  margin-top: clamp(8px, 1.4vh, 16px);
}

/* Onward */

.cs__next {
  max-width: var(--cs-max);
  margin: clamp(52px, 8vh, 104px) auto 0;
  border-top: 1px solid var(--hairline);
  border-bottom: 1px solid var(--hairline);
}

.cs__next-link {
  display: flex;
  align-items: center;
  gap: clamp(16px, 2.4vw, 40px);
  padding: clamp(26px, 4.2vh, 52px) 0;
  color: var(--ink);
  text-decoration: none;
}

.cs__next-label {
  flex: none;
  font-size: clamp(9.5px, 0.74vw, 12.5px);
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

/* The pair sits on one line here, not stacked: this is a link, and a link whose
   label occupies two display lines reads as another section heading. */
.cs__next-name {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0 0.34em;
  font-weight: 900;
  font-stretch: 62%;
  font-size: clamp(26px, 3.4vw, 58px);
  line-height: 0.92;
  letter-spacing: -0.008em;
  text-transform: uppercase;
}

.cs__next-name .dline { display: inline; }

.cs__next-arrow {
  width: clamp(26px, 2.2vw, 40px);
  height: auto;
  flex: none;
  margin-left: auto;
  transition: transform 0.35s var(--ease);
}

.cs__next-link:hover .cs__next-arrow { transform: translateX(6px); }

/* Contact */

.cs__contact {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: clamp(20px, 3vw, 48px);
  max-width: var(--cs-max);
  margin: clamp(40px, 6vh, 76px) auto 0;
  padding: clamp(26px, 4vh, 46px) clamp(22px, 3vw, 52px);
  border: 1px solid var(--hairline);
}

.cs__contact-title {
  margin: 0;
  font-weight: 900;
  font-stretch: 62%;
  font-size: clamp(24px, 2.5vw, 42px);
  line-height: 0.92;
  letter-spacing: -0.008em;
  text-transform: uppercase;
}

.cs__contact-copy {
  margin: clamp(9px, 1.3vh, 15px) 0 0;
  color: var(--ink-soft);
  font-size: clamp(13px, 1vw, 16.5px);
  line-height: 1.55;
}

.cs__contact-btn { flex: none; }

/* ---------- blog ----------
   blog.html and post.html. Both run on the case study's .cs scaffold — marker,
   back link, display pair, facts row, lead shot, next link, contact box — so
   this block only adds the two things the case study does not have: a list of
   posts, and a long single column of prose. */

/* The list. Hairline rows, like the facts row and .cs__next: no cards, no
   fill. The whole row is the link. */
.bl { padding-top: clamp(30px, 4.6vh, 56px); }

.bl__list {
  margin: 0;
  padding: 0;
  list-style: none;
  border-bottom: 1px solid var(--hairline);
}

.bl__item { border-top: 1px solid var(--hairline); }

.bl__link {
  display: grid;
  grid-template-columns: clamp(40px, 4vw, 70px) minmax(0, 1fr) auto;
  column-gap: clamp(14px, 2.4vw, 44px);
  align-items: start;
  padding: clamp(26px, 4.2vh, 50px) 0;
  color: var(--ink);
  text-decoration: none;
}

/* The same outlined hairline numeral as .marker__index and .cs__step-num. */
.bl__num {
  font-size: clamp(26px, 2.6vw, 44px);
  font-weight: 900;
  font-stretch: 62%;
  line-height: 0.8;
  letter-spacing: 0.02em;
  font-variant-numeric: tabular-nums;
  color: transparent;
  -webkit-text-stroke: 1px var(--hairline);
  transition: -webkit-text-stroke-color 0.3s ease;
}

.bl__body { display: block; min-width: 0; }

.bl__meta {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.35em 0;
  font-size: clamp(9.5px, 0.74vw, 12.5px);
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.bl__meta > :not(:last-child)::after {
  content: "\00b7";
  margin: 0 0.8em;
}

.bl__project { color: var(--ink); }

/* The display cut, all solid. The solid/outline pair is spent once per page,
   on the page heading; repeated down a list it turned every row into a poster.
   The type stays the page's own, only the outline half is filled in. */
.bl__title {
  margin: clamp(10px, 1.5vh, 16px) 0 0;
  font-size: clamp(30px, 4vw, 66px);
  font-weight: 800;
  font-stretch: 62%;
  line-height: 0.92;
  letter-spacing: -0.004em;
  text-transform: uppercase;
  color: var(--ink);
  text-decoration: underline;
  text-decoration-color: transparent;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.18em;
  transition: text-decoration-color 0.3s ease;
}

.bl__excerpt {
  display: block;
  max-width: 58ch;
  margin-top: clamp(12px, 1.8vh, 18px);
  color: var(--ink-soft);
  font-size: clamp(14.5px, 1.02vw, 17px);
  line-height: 1.55;
}

.bl__arrow {
  align-self: center;
  width: clamp(26px, 2.2vw, 40px);
  height: auto;
  transition: transform 0.35s var(--ease);
}

/* Hover underlines the title, darkens the numeral and nudges the arrow. */
.bl__link:hover .bl__title { text-decoration-color: currentColor; }
.bl__link:hover .bl__num { -webkit-text-stroke-color: var(--ink); }
.bl__link:hover .bl__arrow { transform: translateX(6px); }

/* The list page's heading: lede under the title rather than beside it, and a
   step down from the case study's title size. */
.page--blog .cs__title { font-size: clamp(40px, 6.6vw, 104px); }

.bl-intro {
  grid-template-columns: minmax(0, 1fr);
  row-gap: clamp(16px, 2.4vh, 26px);
}

.bl-intro .cs__lede { max-width: none; margin-bottom: 0; }

/* Next post, on the post page — plain for the same reason as the list. */
.bl__next-name {
  font-size: clamp(19px, 1.6vw, 26px);
  font-weight: 600;
  line-height: 1.25;
  letter-spacing: -0.015em;
}

.bl__empty {
  margin: 0;
  color: var(--ink-soft);
  font-size: clamp(15px, 1.14vw, 19px);
}

/* The article. Left-aligned under the title rather than centred, so the
   reading column hangs off the same edge as everything above it. */
.bl__article { max-width: var(--cs-measure); }

.bl__article p {
  margin: 0 0 1.05em;
  color: var(--ink-soft);
  font-size: clamp(15px, 1.14vw, 19px);
  line-height: 1.68;
}

.bl__article > :first-child { margin-top: 0; }
.bl__article > :last-child { margin-bottom: 0; }

.bl__h2 {
  margin: 2.1em 0 0.75em;
  font-weight: 900;
  font-stretch: 62%;
  font-size: clamp(24px, 2.3vw, 38px);
  line-height: 0.95;
  letter-spacing: -0.004em;
  text-transform: uppercase;
  color: var(--ink);
}

.bl__article .cs__list { margin: 0 0 1.4em; }

/* Code is set on the shot's dark material: the page already has one dark
   surface for "this is a screen", and a code block is the same thing. */
.bl__pre {
  margin: 1.5em 0 1.7em;
  padding: clamp(16px, 1.8vw, 26px) clamp(18px, 2vw, 30px);
  overflow-x: auto;
  border-radius: 10px;
  background: #0E0E0E;
  box-shadow: 0 18px 44px -20px rgba(58, 44, 32, 0.45);
  color: var(--bone);
  font-family: ui-monospace, "Cascadia Code", "SF Mono", Consolas, Menlo, monospace;
  font-size: clamp(12.5px, 0.92vw, 15px);
  line-height: 1.65;
  tab-size: 2;
}

.bl__pre:focus-visible { outline-offset: 3px; }

/* The post page has no masthead, so it needs less clearance above the header. */
.page--post { padding-top: clamp(44px, 7vh, 84px); }

/* A post is one centred reading column, header included — the layout every
   blog a reader has used already has. Narrowing --cs-max narrows every block
   the .cs scaffold places (header, article, next link, contact) together, so
   they all share one left edge. */
.page--post .cs { --cs-max: 760px; }

/* The header, top to bottom: back, meta, title, lede, stack, then a rule that
   closes it off from the article. */
.bl-head {
  padding-bottom: clamp(28px, 4.2vh, 46px);
  border-bottom: 1px solid var(--hairline);
}

.bl-head .cs__back { margin-top: 0; }

.bl-head__meta { margin: clamp(30px, 4.6vh, 52px) 0 0; }

.bl-head__title {
  display: flex;
  flex-wrap: wrap;
  gap: 0 0.28em;
  margin: clamp(14px, 2vh, 22px) 0 0;
  font-weight: 900;
  font-stretch: 62%;
  font-size: clamp(40px, 5.6vw, 80px);
  line-height: 0.9;
  letter-spacing: -0.004em;
  text-transform: uppercase;
}

.bl-head__title .dline { display: inline; }

.bl-head__lede {
  margin: clamp(18px, 2.6vh, 28px) 0 0;
  color: var(--ink-soft);
  font-size: clamp(17px, 1.36vw, 22px);
  line-height: 1.5;
}

.bl-head__tags { margin-top: clamp(18px, 2.6vh, 26px); }

/* The column is already a reading measure, so the article takes all of it and
   both edges line up with the header's. */
.page--post .bl__article { max-width: none; }
.bl-head + .cs__block { padding-top: clamp(32px, 5vh, 54px); }

/* The #top target. Absolute against the initial containing block, so it sits
   at y = 0 regardless of the body's padding — a scroll to it is a scroll to
   the very top. */
.bl-anchor {
  position: absolute;
  top: 0;
  left: 0;
  width: 1px;
  height: 1px;
}

/* Reading progress. Ink, not amber: amber means "this is him / he is
   available", and a progress bar is neither. No transition — it is driven by
   the scroll position every frame, and easing it would make it lag the page. */
.bl-progress {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 40;
  height: 3px;
  pointer-events: none;
}

.bl-progress__fill {
  display: block;
  height: 100%;
  background: var(--ink);
  transform: scaleX(0);
  transform-origin: 0 50%;
}

/* Back to top — the filled button's material, as a square. Hidden only when
   JS is present to reveal it; without JS it is simply always there. */
.bl-top {
  position: fixed;
  right: var(--gutter-head);
  bottom: clamp(16px, 3.4vh, 34px);
  z-index: 40;
  display: grid;
  place-items: center;
  width: clamp(46px, 3.4vw, 58px);
  aspect-ratio: 1;
  border-radius: 6px;
  background: var(--btn);
  color: var(--bone);
  box-shadow: 0 18px 44px -20px rgba(58, 44, 32, 0.45);
  transition: opacity 0.3s ease, transform 0.35s var(--ease), background-color 0.3s ease, visibility 0s;
}

.bl-top svg {
  width: auto;
  height: 46%;
  transition: transform 0.35s var(--ease);
}

.js .bl-top:not(.is-visible) {
  opacity: 0;
  visibility: hidden;
  transform: translateY(12px);
  transition: opacity 0.3s ease, transform 0.35s var(--ease), visibility 0s 0.35s;
}

.bl-top:hover { background: #241a1e; }
.bl-top:hover svg { transform: translateY(-3px); }

.bl-code {
  padding: 0.1em 0.35em;
  border-radius: 4px;
  background: rgba(22, 22, 22, 0.07);
  color: var(--ink);
  font-family: ui-monospace, "Cascadia Code", "SF Mono", Consolas, Menlo, monospace;
  font-size: 0.86em;
}

/* ---------- motion ----------
   One authored entrance, orchestrated once. Content is visible by default;
   the hidden start state only exists when JS is present to undo it. */

/* Registered so they can be TRANSITIONED. An unregistered custom property is an
   opaque token to the animation system and snaps. Where @property is missing
   the whole entrance degrades to a snap, never to a stuck start state, because
   every hidden value below is .js-scoped and .is-loaded is guaranteed by the
   failsafe timer in script.js. */
@property --enter-y {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

@property --draw {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 100%;
}

@property --fade-start {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 72%;
}

@property --fade-end {
  syntax: "<percentage>";
  inherits: false;
  initial-value: 100%;
}

/* Every transform here re-declares scaleX: it is layout, not motion, and a
   bare translateY would silently un-condense the headline. */
/* BEAT ONE — the type sets. Two inks, two arrivals.

   The solid line keeps the bottom-up wipe: a horizontal cut across a FILLED
   letterform reads as the letter being stamped out of the ground. The outline
   line used to get the identical treatment and it was the entrance's worst
   frame — the same cut across hollow letters leaves their strokes floating
   disconnected, which reads as broken rendering rather than as a reveal.

   So the outline line does the thing it actually is instead: it DRAWS, left to
   right, behind a soft-edged mask, as if a pen were crossing it. The two lines
   now arrive by two different mechanisms, which is the exact distinction the
   whole composition rests on — weight against contour. */
.js .headline__line--solid {
  clip-path: inset(100% 0 0 0);
  transform: scaleX(var(--squeeze)) translateY(0.09em);
}

.js.is-loaded .headline__line--solid {
  clip-path: inset(0 0 0 0);
  transform: scaleX(var(--squeeze)) translateY(0);
  transition: clip-path 0.52s var(--ease), transform 0.52s var(--ease);
}

/* -14% start puts the whole 14%-wide feather off the left edge, so nothing of
   the line is showing on the first frame. The mask is .js-scoped: with no
   script there is no mask at all, rather than a mask sitting at a value nothing
   will ever change. */
.js .headline__line--outline {
  --draw: -14%;
  -webkit-mask-image: linear-gradient(to right, #000 var(--draw), transparent calc(var(--draw) + 14%));
  mask-image: linear-gradient(to right, #000 var(--draw), transparent calc(var(--draw) + 14%));
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}

/* --rise, not --ease: a pen crossing a line is travel you follow, and under
   --ease the stroke would be 88% across before the eye caught it. */
.js.is-loaded .headline__line--outline {
  --draw: 100%;
  transition: --draw 0.62s var(--rise) 0.08s;
}

/* BEAT TWO — he steps out of the poster.

   The figure does not fade in. It rises out of its own dissolve: the mask's two
   stops start bunched at the very top of the frame and travel down the body, so
   what you watch is the head surfacing out of the bone ground and the torso
   developing behind it. The old opacity fade is gone entirely — it was the one
   generic gesture in the sequence, and it made the figure materialise where it
   already was instead of arriving.

   This is also the first half of a sentence the page already finishes: the
   handoff drives these same channels in reverse to carry him into the About
   frame. He surfaces here, he leaves there. */
.js .hero__portrait { --enter-y: 96px; }

.js .hero__portrait img {
  --fade-start: 0%;
  --fade-end: 9%;
}

.js.is-loaded .hero__portrait {
  --enter-y: 0px;
  transition: --enter-y 0.72s var(--rise) 0.38s;
}

.js.is-loaded .hero__portrait img {
  --fade-start: 72%;
  --fade-end: 100%;
  transition: --fade-start 0.72s var(--rise) 0.38s, --fade-end 0.72s var(--rise) 0.38s;
}

/* The entrance owns these channels; the handoff owns them OUTRIGHT once armed.
   update() writes --fade-start inline on every scroll frame, and a transition
   still attached to it re-triggers on each of those writes — with a 0.38s delay
   in front, the value never leaves where it started for as long as the scroll
   is continuous, then eases to wherever the scroll happened to stop. The
   symptom is a dissolve that sits frozen while the figure travels and snaps
   when you let go.

   Four classes, so this beats .js.is-loaded (three) whatever the source order
   later becomes. --enter-y goes too: GSAP writes `transform` inline while armed,
   so the property is inert there, and leaving it transitionable only leaves a
   trap for the next thing that reads it back. */
.js.is-loaded.is-handoff .hero__portrait,
.js.is-loaded.is-handoff .hero__portrait img {
  transition: none;
}

/* The lede animates as one block, not as a sentence followed by its own
   footnote: the place and availability line is part of the same statement and
   staggering it would make it read as a separate arrival. */
.js .wordmark,
.js .masthead__nav,
.js .masthead .social,
.js .lede,
.js .actions,
.js .anchor {
  opacity: 0;
  transform: translateY(12px);
}

.js.is-loaded .wordmark,
.js.is-loaded .masthead__nav,
.js.is-loaded .masthead .social,
.js.is-loaded .lede,
.js.is-loaded .actions,
.js.is-loaded .anchor {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 0.48s ease var(--d, 0s), transform 0.48s var(--ease) var(--d, 0s);
}

/* BEAT THREE — the page composes itself AROUND him.

   These used to start at 0.30s, which put the masthead, the figure and a
   half-wiped headline all on screen part-formed at the same instant: three
   unfinished things at once, so nothing ever landed. They now begin at 0.60s,
   with the type already set and the figure already surfacing, and the whole
   group is shorter (0.48s, not 0.7s) so it reads as the frame being furnished
   rather than as a sixth separate animation.

   The order also changed at the tail: .actions is last, not .anchor. The
   sequence used to finish its final movement on the email block — the quietest
   thing on the page — while the two CTAs had already settled. */
.js.is-loaded .wordmark          { --d: 0.60s; }
.js.is-loaded .masthead__nav     { --d: 0.65s; }
.js.is-loaded .lede              { --d: 0.66s; }
.js.is-loaded .masthead .social  { --d: 0.70s; }
.js.is-loaded .anchor            { --d: 0.71s; }
.js.is-loaded .actions           { --d: 0.76s; }

/* BEAT FOUR — the amber square, alone, with nothing else moving.

   The page's one colour, and it means one thing: he is open to work. It gets
   the last word, 0.4s after the line it belongs to has settled, so it registers
   as a statement rather than as part of a fade.

   Scale, not opacity, and that is load-bearing: `breathe` already animates this
   element's opacity forever, and an animation beats a transition on the same
   property — an opacity entrance here would simply never be seen. */
.js .hero .live { transform: scale(0); }

.js.is-loaded .hero .live {
  transform: scale(1);
  transition: transform 0.42s var(--ease) 1.05s;
}

/* ---------- the handoff ----------
   "He steps out of the poster." One figure, not two: the hero portrait detaches
   from the display type, shrinks, and lands inside the About frame.

   `.is-handoff` is set by script.js and ONLY after gsap, ScrollTrigger, a fine
   enough viewport, and no reduced-motion preference have all been confirmed. It
   is the switch between two complete, shippable compositions:

     without it — hero figure absolute in the hero, About shows its own static
                  portrait. Two portraits. Reads correctly, no motion, nothing
                  depends on a script having run.
     with it    — hero figure goes fixed and is driven by transform; the About
                  portrait is hidden but still occupies its box, because that
                  box is the measured target the figure is flying toward.

   visibility, never display: display:none would collapse the slot and the
   figure would have nothing to aim at. */

.is-handoff .about__portrait { visibility: hidden; }

/* ABSOLUTE, NOT FIXED, AND THE DIFFERENCE IS THE WHOLE POINT.

   Fixed meant the figure did not move with the page at all: every pixel of it
   appearing to sit still inside the About frame was script.js rewriting its
   transform each frame to cancel scrollY. So the frame was scrolled by the
   browser and the figure was repainted by us, and they only stayed together on
   frames where our code ran in time. Measured, it never did: the figure was
   repositioned 16ms at best and 50ms at worst after the scroll event — one to
   three frames late, every time. At a slow scroll that is a few pixels and
   invisible; at a fast one it is ~100px per frame of separation, which is the
   drag the owner reported and the snap back when the main thread caught up.

   Absolute puts the figure in DOCUMENT space, so the browser scrolls it along
   with the frame for free. Past the landing its transform is constant, so a
   late frame costs nothing at all rather than costing a whole scroll delta.

   This is not the fixed → absolute swap at the end of the travel that DESIGN.md
   rules out — that rule is about changing positioning models mid-effect, at the
   exact moment the arrival has to look seamless. This is one model for the
   whole travel, and still one element the whole way.

   It requires the figure to be moved OUT of .hero in the DOM while armed, which
   script.js does, because .hero is overflow: hidden and would clip an absolute
   child the moment it travelled below the fold. Nothing is lost by leaving:
   overflow: hidden never clipped the fixed figure either, so the clipping
   behaviour here is unchanged. */
.is-handoff .hero__portrait {
  position: absolute;
  /* Zeroed so the figure's placement is pure transform from the document
     origin. The percentage offsets that centre it in the hero are layout for
     the static composition and would fight the measured travel. */
  left: 0;
  top: 0;
  transform-origin: 0 0;
}

/* The prose's hidden start state is deliberately NOT declared here. It is set
   from script.js with gsap.set(), one statement before the tween that undoes
   it. In CSS it would be a rule that hides real content the moment .is-handoff
   lands, and any failure after that point — a thrown error, a killed timeline —
   would leave the section permanently blank. Setting it in JS means the only
   code that can hide the prose is the code that is already committed to
   revealing it. Mirrors the existing rule that start states are .js-scoped. */

@media (prefers-reduced-motion: reduce) {
  .js .headline__line,
  .js.is-loaded .headline__line {
    clip-path: none;
    transform: scaleX(var(--squeeze));
  }

  /* The draw is travel, so it goes entirely — mask removed rather than jumped
     to 100%, so there is no masked layer left doing nothing. */
  .js .headline__line--outline,
  .js.is-loaded .headline__line--outline {
    --draw: 100%;
    -webkit-mask-image: none;
    mask-image: none;
  }

  .js .headline__line { opacity: 0; }
  .js.is-loaded .headline__line {
    opacity: 1;
    transition: opacity 0.45s ease;
  }
  .js.is-loaded .headline__line--outline { transition: opacity 0.45s ease; }

  /* The portrait is deliberately absent from the transform resets below:
     its transform also carries translateX(-50%), which is layout, not motion.
     Zeroing --enter-y stops the movement without decentring the figure. */
  .js .hero__portrait,
  .js.is-loaded .hero__portrait { --enter-y: 0px; }

  /* The dissolve is the figure's arrival mechanism now, so under reduced motion
     it has to be held at rest and the plain opacity fade put back — otherwise
     this path would still animate the mask down the body, which is exactly the
     travel the setting asks us not to run. */
  .js .hero__portrait { opacity: 0; }
  .js.is-loaded .hero__portrait {
    opacity: 1;
    transition: opacity 0.45s ease;
  }

  .js .hero__portrait img,
  .js.is-loaded .hero__portrait img {
    --fade-start: 72%;
    --fade-end: 100%;
    transition: none;
  }

  .js .hero .live,
  .js.is-loaded .hero .live {
    transform: none;
    transition: none;
  }

  .js .wordmark,
  .js .masthead__nav,
  .js .masthead .social,
  .js .lede,
  .js .actions,
  .js .anchor { transform: none; }

  .js.is-loaded .wordmark,
  .js.is-loaded .masthead__nav,
  .js.is-loaded .masthead .social,
  .js.is-loaded .lede,
  .js.is-loaded .actions,
  .js.is-loaded .anchor {
    transform: none;
    transition: opacity 0.45s ease;
  }

  .social__link:hover,
  .btn:hover,
  .btn:hover .btn__arrow,
  .work__cta:hover .work__arrow { transform: none; }
}

/* ---------- breakpoints ---------- */

/* Very wide AND proportionally short — the only state where the headline is
   height-bound, stops reaching the margins, and leaves real side space. Rather
   than scale the type up into the tagline, that space is spent inside the line:
   the measure widens and the word gap opens to fill it. Only the
   Backend/Engineer and System/Architect pairs open; the "&" keeps a normal
   space, so both lines widen by one gap each and stay flush with each other.

   The aspect guard is not decoration. Width alone is the wrong test: 1920x1080
   is wider than 1800 but width-bound, with no side space at all, and opening
   the gap there pushes the headline straight past the right margin. Past 19/10
   both the measure and the type size are governed by vh, so a single gap value
   holds flush across every aspect in this range. */
/* No wide-screen word-gap rule. Opening the pairs on very wide screens was
   tried and rejected: the extra space reads as a broken line rather than as
   deliberate breathing room. Wide viewports simply keep the height-bound
   measure and grow their side margins instead. */

/* Tablet — headline holds two lines, portrait scales down, the contact
   anchor drops beneath the tagline. */
@media (max-width: 1199px) {
  :root {
    --h1a: min(14.10vw, 25.06vh);
    --h1b: min(14.19vw, 25.22vh);
  }

  .hero__portrait img { height: min(72vh, 62vw); }

  .hero__band {
    flex-direction: column;
    align-items: flex-start;
    gap: clamp(16px, 2.4vh, 26px);
  }

  .anchor { align-items: flex-start; }

  .actions { margin-top: clamp(16px, 3vh, 28px); }
}

/* Three columns stop working before the phone breakpoint does: the middle
   stats column squeezes the text under ~34 characters a line. Text and stats
   pair up on one row and the frame takes the row beneath them. Scoped to
   .has-handoff — without the frame the base two-column layout already applies
   and needs no override here. */
@media (max-width: 1080px) {
  .has-handoff .about__grid {
    grid-template-columns: minmax(0, 1.25fr) minmax(0, 0.75fr);
    grid-template-areas:
      "title title"
      "text  stats"
      "frame frame";
  }

  .has-handoff .about__frame {
    justify-self: start;
    width: min(52%, 330px);
  }
}

@media (max-width: 900px) {
  .navlist { gap: clamp(14px, 2vw, 26px); }
}

/* Work rows stop working as two columns well before the phone breakpoint. The
   text column carries a numeral rail, a title, two separated lists and a
   paragraph; squeeze it under ~330px and the tag row wraps to three lines while
   the shot beside it is still legible. The row becomes body-over-shot instead,
   which also puts the screenshot at full measure where it can actually be read.

   Rows no longer alternate, so there are no even-row placements to undo here —
   a single-column template is the whole override. */
@media (max-width: 900px) {
  .work__head {
    grid-template-columns: minmax(0, 1fr);
    row-gap: clamp(16px, 2.4vh, 26px);
  }

  .work__blurb { margin-bottom: 0; }

  .work__item {
    grid-template-columns: minmax(0, 1fr);
    row-gap: clamp(26px, 4vh, 44px);
  }

  /* Stacked, the screenshot goes ABOVE its copy: it is the evidence, and on a
     narrow screen a reader meets the row before deciding whether to read it.
     Done with grid-row rather than by reordering the markup, so the DOM keeps
     copy-then-shot — assistive tech still hears the project name first, and
     the row's one link stays where the tab order expects it.

     The desktop width cap and its `margin-left: auto` both come off here. They
     exist to hold the shot flush to the RIGHT margin in a two-column row; in a
     single column they indent it away from the left margin the copy beneath it
     is aligned to — 200px of it at 900px wide, where the column is wider than
     the 560px cap. Below ~640px the cap stops binding and the fault hides
     itself, which is why it is reset rather than left to luck. */
  .work__shot {
    grid-row: 1;
    max-width: none;
    margin-left: 0;
  }

  .work__body { grid-row: 2; }
}


/* Phone — headline breaks to four lines, the portrait stays in front of the
   type (see below), CTAs stack full-width, nav collapses to wordmark plus
   contact. */
@media (max-width: 767px) {
  :root {
    /* Four lines of unequal length: "Developer" is the widest and sets the
       measure, so both spans share one size and the rag falls where it falls.
       No squeeze here — four lines at desktop's cap height would collide with
       the copy, and at phone optical sizes the extra condensation buys nothing. */
    /* Portrait phones are never height-bound on this block, so no vh cap.
       Caps are wider than lowercase, so these run well below the desktop
       coefficients. Sized per span to the widest line each one holds —
       DEVELOPER on one, "& SYSTEMS" on the other. Retuned by measurement on
       2026-10-17 when "System Architect" became "Systems Engineer"; the four
       lines still stack into one rectangle. */
    --h1a: 20.91vw;
    --h1b: 21.82vw;
    --measure: auto;
    --squeeze: 1;
    --gutter: 22px;
    --gutter-head: 22px;

    /* ONE phone rhythm for every section join below the hero. A gap between
       two sections is the sum of one's bottom padding and the next one's top,
       so a single value makes every join identical and self-evidently so.
       Work->Skills was 169px and Skills->Contact 160px, because Skills and
       Contact had no phone overrides at all and were still running the desktop
       11vh/10vh; both are now 118px. */
    --sec-pad: clamp(46px, 7vh, 68px);
  }

  .masthead { padding-block: 16px; }
  .masthead__nav,
  .masthead .social { display: none; }
  .burger { display: block; }

  /* Uppercase on phones. Four short lines of caps stack into a solid block with
     no ascender/descender ragging, which is what lets the type read as
     architecture at this size rather than as a shrunken desktop headline. */
  .headline { text-transform: uppercase; }

  .hero {
    display: flex;
    flex-direction: column;
    justify-content: flex-end;
    padding: clamp(84px, 12vh, 112px) var(--gutter) clamp(22px, 3.5vh, 34px);
  }

  /* Both layers stay positioned so they keep a stacking context. Left static,
     they would paint beneath the absolutely-positioned portrait regardless of
     its z-index, and the figure would cover the tagline and the CTAs. */
  /* z-index:auto is deliberate — it stops this block forming a stacking context
     so the two headline lines can be ordered independently around the figure. */
  .hero__type {
    position: relative;
    z-index: auto;
    /* Relative positioning honours `left` as a shift rather than an edge, so
       the absolute-layout offsets have to be cleared or the block slides out
       past the right margin. The hero's own padding sets the measure here. */
    left: auto;
    right: auto;
    top: auto;
    transform: none;
    margin-bottom: auto;
  }

  /* The phone composition is a sandwich, not a stack: the solid block sits
     under the figure, the outlined block sits over it. The figure interrupts
     the line it emerges from and is then overwritten by the one it stands
     behind, which is what makes the two blocks read as different planes rather
     than as one paragraph with a picture laid on top. */
  .headline__line--solid {
    position: relative;
    z-index: 1;
  }

  .headline__line--outline {
    position: relative;
    z-index: 3;
    /* The face lives in this gap. Without it the two blocks close up and the
       portrait has nowhere to be except on top of the type. */
    margin-top: clamp(60px, 17vh, 160px);
  }

  .hero__ui {
    position: relative;
    z-index: 5;
    padding: 0;
  }

  /* The figure stays IN FRONT of the type here, as it does on desktop — that
     occlusion is the page's signature, and reversing it on small screens lets
     solid letterforms cut across the eyes. It drops to sit against the two
     outlined lines instead, where a face reads cleanly through open counters,
     and clears the copy below entirely. */
  /* Centred, not bled off the right edge, and sitting in the gap between the
     two heading blocks — the face clear of both, the torso running down behind
     the outlined lines. */
  .hero__portrait {
    z-index: 2;
    left: 50%;
    right: auto;
    top: 16vh;
    bottom: auto;
    transform: translate(-50%, var(--enter-y, 0px));
  }

  /* The figure has to stop mid-torso here rather than bleeding off a viewport
     edge, so the crop is dissolved instead of guillotined — a hard horizontal
     cut across the sweatshirt reads as a broken image, not as a portrait. */
  /* The phone figure sits across the outlined lines rather than below them, so
     its fade has to start much later or the type ghosts through the body. */
  .hero__portrait img {
    /* The fade can run long here: the only line beneath the figure is the solid
       block, which sits far above the first stop, so nothing ghosts through. */
    height: min(58vh, 104vw);
    --fade-start: 72%;
  }

  .brk { display: inline; }

  /* Back to a plain space so the <br> owns the break and the heading still
     reads as one sentence when the gap token is not in play. */
  .wgap {
    display: inline;
    width: auto;
  }

  /* The rise is proportional to the figure, not absolute: 96px against a
     ~620px desktop portrait is a lift, the same 96px against a phone one is a
     lurch, and the phone figure also has far less headroom above it. */
  .js .hero__portrait { --enter-y: 64px; }

  .headline__line--outline { --stroke: 1.2px; }

  .tagline {
    max-width: 30ch;
    font-size: 17px;
  }

  /* The email lives in the menu and behind Get In Touch on phones; the hero
     keeps only the two facts worth the space. */
  .anchor__row,
  .anchor__feedback { display: none; }

  .meta {
    flex-direction: row;
    flex-wrap: wrap;
    gap: 6px 16px;
    font-size: 14.5px;
  }

  .anchor__meta { justify-content: flex-start; }

  /* Paired, not stacked: two equal actions read as a choice, and stacking them
     full-width makes the secondary one look like a second primary. */
  .actions {
    flex-direction: row;
    gap: 10px;
    margin-top: clamp(16px, 2.6vh, 26px);
  }

  /* The inline padding is set against the longest label, not by eye: at 375px
     "View My Projects" plus its arrow needs all but ~36px of the button, and
     14px sides put it a fraction of a pixel over, wrapping one CTA to two lines
     beside a neighbour that stays on one. 12px clears it down to ~366px. */
  .btn {
    flex: 1 1 0;
    justify-content: space-between;
    gap: 7px;
    padding: 15px 12px;
    font-size: 13.5px;
  }

  .btn__arrow { width: 21px; }

  /* About on phones. One column, and no frame at all — the handoff never arms
     below 768px, so a portrait here would simply be the hero's figure printed
     a second time one screen further down. The section reads as type. */
  /* The 100svh floor comes off here. It exists so the handoff has page beneath
     the hero to land in — and the handoff never arms below 768px, so on phones
     it was only padding a short section out to a full screen and leaving a band
     of empty ground above and below the copy. Padding alone sets the rhythm
     now. Deliberately NOT removed above this breakpoint, where the floor is
     still load-bearing for the travel. */
  .about {
    min-height: 0;
    /* The bottom value is once again a plain About-to-Projects gap. It was
       3.5vh when those two sections met directly, was raised to --sec-pad when
       the marquee was inserted between them (so the strip would get equal air
       top and bottom rather than sitting high in its own gap), and the marquee
       was removed in 2026-09. --sec-pad is kept rather than reverted: it is the
       value every other section boundary on the page uses, and 3.5vh only ever
       existed to mirror the hero's bottom padding one section earlier. */
    padding: clamp(52px, 8vh, 76px) var(--gutter) var(--sec-pad);
  }

  .about__grid {
    grid-template-columns: minmax(0, 1fr);
    grid-template-areas:
      "title"
      "text"
      "stats";
    row-gap: clamp(22px, 3.4vh, 32px);
  }

  .about__title,
  .work__heading,
  .skills__heading { font-size: var(--display-2-sm); }

  .about__text p { max-width: none; font-size: 15.5px; }

  /* The stats stop being a stacked column and become a row: three short
     figures in a vertical stack push the panel a whole screen further down for
     no gain, and they read perfectly well side by side at this size. */
  .about__stats {
    flex-direction: row;
    gap: clamp(12px, 3.4vw, 20px);
  }

  .about__stat { flex: 1 1 0; }
  .about__figure { font-size: clamp(26px, 7.8vw, 36px); }
  .about__statlabel { font-size: 9.5px; letter-spacing: 0.1em; }

  /* Work on phones. The rail survives — it is what keeps a stacked row reading
     as a numbered entry rather than as a heading with loose parts under it —
     but everything it indents gets tighter, because at 375px the desktop indent
     costs a fifth of the measure. */
  .work {
    padding: var(--sec-pad) var(--gutter) var(--sec-pad);
    --num-size: 30px;
    --num-col: 40px;
  }

  /* Neither of these had a phone override, so both were still on the desktop
     clamps — Skills opened on 11vh and closed on 10vh, which is where most of
     the dead space between Projects, Skills and the footer was coming from. */
  .skills  { padding: var(--sec-pad) var(--gutter) var(--sec-pad); }
  .contact { padding-top: var(--sec-pad); }

  .work__blurb { font-size: 15px; max-width: none; }
  .work__item { padding: clamp(28px, 4.4vh, 40px) 0; }
  --name-size: clamp(27px, 8.6vw, 46px);
  .work__desc { font-size: 15px; max-width: none; }
  .work__tech { font-size: 13.5px; }

  /* The label CTA keeps its underline but loses the wide gap: at this size the
     arrow was drifting a third of the screen away from the words it belongs
     to. */
  .work__cta { gap: 12px; font-size: 11px; }
  .work__arrow { width: 22px; }

  /* Side by side, the title and the button leave neither enough room. Stacked,
     the button takes the full measure and matches the hero's phone CTAs. */
  .work__more {
    flex-direction: column;
    align-items: stretch;
    padding: clamp(22px, 3.4vh, 30px) clamp(18px, 5vw, 26px);
  }

  .work__more-title { font-size: clamp(23px, 7.4vw, 34px); }
  .work__more-copy { font-size: 14.5px; }

  /* flex:none is overridden by the generic .btn { flex: 1 1 0 } in this same
     block, so the width is restated rather than assumed. */
  .work__more-btn {
    flex: none;
    justify-content: space-between;
    width: 100%;
  }
}

/* Short phones — an iPhone SE at 667px, or any phone whose URL bar is showing.
   Below roughly 700px the block stops being viewport-bound and becomes
   content-bound: four lines of caps, two meta rows and the CTAs add up to more
   than the viewport, and the hero grows past 100svh into a few pixels of its
   own. The gap the figure lives in is the only slack worth taking — it is
   the largest single measurement on the phone layout and the one whose loss
   costs the least, since a shorter viewport gives the face less room to occupy
   anyway. Tall phones keep the designed gap untouched. */
@media (max-width: 767px) and (max-height: 700px) {
  .headline__line--outline { margin-top: clamp(48px, 13.5vh, 160px); }
}

/* Case study — the facts row and the heading rail both give out around the
   same width. Four fact cells under ~900px leave "Design & fullstack build"
   wrapping to three lines under a one-word label; two by two reads correctly
   and keeps the hairline rhythm. The h2 rail collapses at the same point: a
   heading that has become narrower than its own longest word is no longer
   holding a column. */
@media (max-width: 900px) {
  .cs__facts { grid-template-columns: repeat(2, minmax(0, 1fr)); }

  .cs__split {
    grid-template-columns: minmax(0, 1fr);
    row-gap: clamp(18px, 2.8vh, 30px);
  }

  /* The opening pair collapses with the same reasoning as .cs__split: below
     this width the lede column is narrower than the display type beside it. */
  .cs__opening {
    grid-template-columns: minmax(0, 1fr);
    row-gap: clamp(16px, 2.4vh, 26px);
  }

  .cs__lede { max-width: none; margin-bottom: 0; }

  /* A 21/9 letterbox at this measure is about 190px tall and the plates stop
     being legible, which is the one thing the crop exists to show. */
  .shot--crop img { aspect-ratio: 16 / 9; }
}

@media (max-width: 767px) {
  .page--cs { padding-top: clamp(62px, 9vh, 84px); }

  .cs { padding: 0 var(--gutter) clamp(56px, 9vh, 84px); }

  .cs__title { font-size: clamp(42px, 15vw, 76px); }
  .cs__lede { font-size: 16.5px; max-width: none; }
  .cs__h2 { font-size: clamp(27px, 8.4vw, 44px); }
  .cs__prose p { font-size: 15.5px; }
  .cs__list li { font-size: 15px; }
  .cs__figure { font-size: clamp(28px, 8.6vw, 40px); }
  .cs__statlabel { font-size: 9.5px; letter-spacing: 0.1em; }
  .cs__step-title { font-size: 17px; }
  .cs__step-body p { font-size: 15px; }
  .cs__caption { font-size: 12.5px; }

  /* One per row. Two fact cells at 375px put "Next.js · TypeScript · Sanity ·
     Vercel" into a 150px column, which sets it as five lines of one word. */
  .cs__facts {
    grid-template-columns: minmax(0, 1fr);
    gap: clamp(12px, 2vh, 18px);
  }

  /* The label and the name stop sharing a line, and the arrow goes with the
     name — an arrow pinned right on a two-line block floats level with
     nothing. */
  .cs__next-link {
    flex-direction: column;
    align-items: flex-start;
    gap: clamp(10px, 1.6vh, 16px);
  }

  .cs__next-name { font-size: clamp(26px, 8.4vw, 44px); }
  .cs__next-arrow { margin-left: 0; width: 30px; }

  .cs__contact {
    flex-direction: column;
    align-items: stretch;
    padding: clamp(22px, 3.4vh, 30px) clamp(18px, 5vw, 26px);
  }

  .cs__contact-title { font-size: clamp(23px, 7.4vw, 34px); }
  .cs__contact-copy { font-size: 14.5px; }

  /* Blog rows drop the numeral column: at phone width it costs a fifth of the
     line for a number the list already announces. The arrow stays, pinned to
     the title rather than floating mid-row. */
  .bl__link {
    grid-template-columns: minmax(0, 1fr) auto;
    column-gap: 16px;
  }
  .bl__num { display: none; }
  .bl__arrow { align-self: start; margin-top: 2px; width: 24px; }
  .bl__title { font-size: clamp(27px, 8.6vw, 44px); }
  .bl__excerpt { font-size: 15px; }
  .bl__article p { font-size: 15.5px; }
  .bl__h2 { font-size: clamp(23px, 7vw, 32px); }
  .bl-head__title { font-size: clamp(36px, 11vw, 56px); }
  .bl-head__lede { font-size: 16.5px; }
  .bl__pre { font-size: 12.5px; }

  /* Restated, not assumed: the generic .btn in this same block sets
     flex: 1 1 0, which would otherwise let the button shrink to its content. */
  .cs__contact-btn {
    flex: none;
    justify-content: space-between;
    width: 100%;
  }
}

@media (min-width: 768px) {
  .menu { display: none; }
}

@media (max-width: 767px) and (prefers-reduced-motion: reduce) {
  .js .hero__portrait,
  .js.is-loaded .hero__portrait { --enter-y: 0px; }
}
