/* ==========================================================================
   Felipe Ariza - site styles
   Plain CSS. No build step, no framework. Edit this file directly.

   The look is meant to read as a technical document: one neutral ground, one
   mark colour, hairlines instead of boxes, and a grid module that repeats
   from the hero all the way down. If a rule in here only makes something look
   nicer without saying anything, it shouldn't be in here.
   ========================================================================== */

/* --------------------------------------------------------------------------
   1. Tokens
   -------------------------------------------------------------------------- */
:root {
  /* Black. Actually black, not a blue-black pretending to be one.

     This used to be #05070b, a ground with blue in it, on the theory that a
     tinted ground gives every grey above it a temperature. It does, and that
     is exactly what was making the page read as a *designed* dark theme
     rather than as a serious one. The reference sites are not tinted. They
     are black with a grey scale on top, and everything that looks expensive
     about them comes from value and spacing, never from hue.

     So: one ground, one scale, no hue anywhere. If something needs to stand
     out it gets brighter, not bluer. */
  --base: #000000;

  /* Surfaces are plain white at very low alpha, so a panel is the ground plus
     a film of light rather than a fourth grey somebody has to keep in sync.
     Nesting one inside another lightens it on its own. */
  --surface: rgba(255, 255, 255, 0.045);
  --surface-2: rgba(255, 255, 255, 0.075);
  --surface-3: rgba(255, 255, 255, 0.12);

  /* The type scale, and the whole hierarchy of the page. With no colour left
     to lean on, these three values ARE the information design: white is a
     heading or a signal, mid grey is something to read, dark grey is a label
     you are meant to skip until you need it. Getting a level wrong is now
     much more visible than it was, which is the point. */
  --fg: #ffffff;
  --fg-2: #a3a3a3;
  --fg-3: #6e6e6e;

  /* The loudest the page can go. Same white - there is nothing above white. */
  --accent: #ffffff;
  --accent-hi: #ffffff;
  --on-accent: #000000;

  /* What used to be the ice-blue signal colour.

     The token names are kept so nothing downstream has to be renamed, but
     there is no brand hue any more: a signal is now simply the brightest
     thing in its neighbourhood. An ordinal in white beside a label in --fg-3
     separates exactly as well as a blue one did, and it does it without
     introducing the single saturated colour that made the page look styled.

     If you ever want the blue back, this is the only place to change it and
     the drawing in the hero picks it up automatically. */
  --brand: #ffffff;
  --brand-hi: #ffffff;
  --brand-dim: rgba(255, 255, 255, 0.28);

  /* Form feedback, and the one place two states have to be told apart at a
     glance. They are told apart by value rather than by hue, which is the
     rule everywhere else on the page too: a failure is the brightest thing in
     the form and a success is quiet and recedes. The message itself says
     which is which, so nothing here is carrying meaning on its own. */
  --ok: #d4d4d4;
  --bad: #ffffff;

  /* The availability light, and the only hue on the site. See .marker: a
     status light is the one thing here that answers a question faster in
     colour than it can in words. Kept in its own pair of tokens precisely so
     it can never quietly spread into anything else. */
  --live: #4ea86a;
  --off: #c4453c;

  /* Hairlines are white at low alpha, so they read as an edge catching light
     rather than as a line drawn on top of the page. */
  --hairline: rgba(255, 255, 255, 0.11);
  --hairline-strong: rgba(255, 255, 255, 0.22);

  /* Two families doing two jobs. Archivo carries a width axis, so the display
     sizes can be drawn condensed and set in caps - tall, narrow, packed -
     while running text stays at normal width and stays readable. Plex Mono
     keeps every label, ordinal, spec value and button at a fixed pitch, which
     is the part of the old site worth keeping. */
  --sans: "Archivo", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;

  --ease: cubic-bezier(0.22, 1, 0.36, 1);

  /* Square. Not nearly-square - square. A 3px radius is a decision you can
     see and a decision nobody asked for; zero reads as drawn rather than
     styled. */
  --r: 0px;

  --shell: 1280px;
  --gutter: 20px;

  /* Header height. The header floats over the hero rather than sitting above
     it, so this is only used for padding content out from under the bar. */
  --header-h: 72px;

  /* The grid module. Still the same figure, but it only surfaces under the
     work index now - the hero is a drawing of its own. */
  --module: 72px;

  /* 0 at the top of the page, 1 once the hero has scrolled away. Set by JS;
     the fallback keeps the hero correct if the script never runs. */
  --hero-p: 0;

  color-scheme: dark;
}

@media (min-width: 640px) {
  :root { --gutter: 32px; }
}

@media (min-width: 1024px) {
  :root { --gutter: 48px; }
}

/* --------------------------------------------------------------------------
   2. Reset
   -------------------------------------------------------------------------- */
*,
*::before,
*::after { box-sizing: border-box; }

* { margin: 0; }

html {
  scroll-behavior: smooth;
  /* Sticky header shouldn't cover the section an anchor jumps to. */
  scroll-padding-top: 88px;
  -webkit-text-size-adjust: 100%;
}

body {
  background-color: var(--base);
  color: var(--fg);
  font-family: var(--sans);
  font-size: 16px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

img,
svg { display: block; max-width: 100%; }

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

button,
input,
textarea,
select {
  font: inherit;
  color: inherit;
}

button { background: none; border: 0; cursor: pointer; }

ul, ol { list-style: none; padding: 0; }

::selection {
  background: var(--accent);
  color: var(--on-accent);
}

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

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

.skip-link {
  position: absolute;
  left: -9999px;
  z-index: 100;
}

.skip-link:focus {
  left: 16px;
  top: 16px;
  background: var(--surface-3);
  border: 1px solid var(--hairline-strong);
  border-radius: var(--r);
  padding: 10px 18px;
}

/* --------------------------------------------------------------------------
   3. Layout
   -------------------------------------------------------------------------- */
.shell {
  width: 100%;
  max-width: var(--shell);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

.section { padding-block: clamp(76px, 10vw, 150px); }

/* Sections are separated by a single hairline running the full width of the
   viewport, not by gaps between cards. The page should read as one document
   with rules in it. */
.section--divided { border-top: 1px solid var(--hairline); }

/* A section that carries the hero's grid under it. The plane doesn't stop at
   the fold: it comes back up under the work index - the one part of the page
   that is itself a drawing - and fades out again as you leave, in both
   directions. Opacity and drift are driven by how much of the section is on
   screen, so scrolling back up brings it back rather than replaying an
   entrance animation. */
.section--grid { position: relative; overflow: hidden; }

/* Everything except the grid layer itself rides above it. */
.section--grid > .shell { position: relative; z-index: 1; }

.section__grid {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background-image:
    linear-gradient(to right, rgba(255, 255, 255, 0.05) 1px, transparent 1px),
    linear-gradient(to bottom, rgba(255, 255, 255, 0.05) 1px, transparent 1px);
  background-size: var(--module) var(--module);
  background-position: center top;
  opacity: var(--in, 0);
  /* Fades into the hairlines above and below rather than butting against
     them, so the section still reads as ruled off. */
  -webkit-mask-image: linear-gradient(to bottom, transparent 0%, #000 16%, #000 78%, transparent 100%);
  mask-image: linear-gradient(to bottom, transparent 0%, #000 16%, #000 78%, transparent 100%);
}

/* --------------------------------------------------------------------------
   4. Type
   --------------------------------------------------------------------------
   Two registers and nothing in between. Headlines are caps, condensed, and
   tracked tight enough that three words fill a screen. Anything that labels,
   counts or measures is mono. Running text is the only thing set normally,
   because it is the only thing anyone reads a paragraph of.
   -------------------------------------------------------------------------- */
.eyebrow {
  font-family: var(--mono);
  font-size: 11px;
  line-height: 1;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--fg-3);
}

/* Used once, on the 404 label, where the point is that something went wrong -
   which is a real state, so it gets the one real colour. */
.eyebrow--accent { color: var(--brand); }

/* Section ordinal. The sections are numbered like parts of a document, which
   is also what tells you where you are when you land mid-page from an anchor
   or a search result. */
.eyebrow__n {
  color: var(--brand);
  margin-right: 12px;
}

.eyebrow__n::after {
  content: "";
  display: inline-block;
  vertical-align: middle;
  width: 20px;
  height: 1px;
  margin-left: 12px;
  background-color: var(--hairline-strong);
}

/* The display face. Caps, condensed, set at a line height under 1 so the
   lines stack into a block rather than reading as a list of sentences. The
   width axis does the real work: at wdth 80 the same headline holds two or
   three more characters per line, which is the whole difference between a
   headline and a paragraph in large type. */
.display,
.h1,
.h2 {
  font-weight: 700;
  font-variation-settings: "wdth" 80;
  text-transform: uppercase;
  letter-spacing: 0.004em;
  line-height: 0.92;
  text-wrap: balance;
}

.display {
  /* Capped by viewport height as well as width, so a short-but-wide laptop
     screen doesn't get a headline too tall to fit above the fold. Pulled down
     from its first size: at 124px it was filling the screen edge to edge and
     leaving the drawing beside it nowhere to be. */
  font-size: min(clamp(40px, 7.4vw, 102px), 15vh);
}

.h1 { font-size: clamp(38px, 6.5vw, 74px); }
.h2 { font-size: clamp(32px, 5vw, 60px); }

/* Card and row titles. Caps as well, so the hierarchy is one voice, but only
   lightly condensed - at this size a narrow face reads cramped rather than
   tall, and the tracking has to open up to compensate. */
.h3 {
  font-size: 17px;
  font-weight: 600;
  font-variation-settings: "wdth" 92;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  line-height: 1.3;
}

/* One word carries a rule under it instead of a second colour of text. On a
   monochrome page this is the only emphasis available that isn't just more
   weight, which is why it survived the rebuild. */
.hl {
  position: relative;
  white-space: nowrap;
}

/* Both the weight of the mark and its distance from the baseline are in em,
   so it stays the same mark at every size it is used at. It was 3px at a
   fixed 0.13em, which was tuned for the hero - dropped onto the smaller focus
   line the same bar read as a text-decoration underline crowding the letters
   rather than as a mark drawn under them. min-height keeps it from vanishing
   in small type. */
.hl::after {
  content: "";
  position: absolute;
  left: 0; right: 0;
  bottom: 0.02em;
  height: 0.045em;
  min-height: 2px;
  background-color: var(--fg);
}

/* In the hero the mark draws itself, just after the headline has landed - the
   one place on the page where the eye has nothing else to do yet. The class
   is added and removed by main.js as the hero leaves and comes back, so it
   redraws every time you return to the top rather than once per page load. */
.hero .hl::after { transform-origin: 0 50%; }

.hero.is-live .hero__headline .hl::after {
  animation: hl-draw 0.5s var(--ease) 0.45s both;
}

/* Without JS the class is never added, so the mark just sits there drawn. */
.no-js .hero .hl::after { animation: none; }

@keyframes hl-draw {
  from { transform: scaleX(0); }
  to { transform: scaleX(1); }
}

.lede {
  color: var(--fg-2);
  line-height: 1.62;
  text-wrap: pretty;
}

.section-head { max-width: 56ch; }

/* The heading wants a much shorter measure than the paragraph under it:
   condensed caps at 60px fit roughly eighteen characters before the line
   should turn, and letting it run to the paragraph's width would flatten it
   into a banner. */
.section-head .h2 { margin-top: 24px; max-width: 18ch; }
.section-head .lede { margin-top: 22px; font-size: 16.5px; max-width: 54ch; }

/* Small square status marker. A square rather than a dot, and it doesn't
   pulse - it's a state, not an event.

   This is the one coloured thing left on the site, and it earns the exception
   the way a status light on a machine does: green and red are not decoration
   here, they are the fastest way to answer the only question a visitor has
   before they write to you. Everything else stays on the grey scale.

   The colour lives on the modifiers rather than on .marker itself, because the
   same square is also used as a plain bullet in a couple of places where it
   means nothing at all - and a bullet that lights up green is a status report
   nobody wrote. Bare .marker is neutral; only a real availability light gets a
   hue. */
.marker {
  display: inline-block;
  width: 6px; height: 6px;
  background-color: var(--fg);
  flex-shrink: 0;
}

/* The availability light. Swap the class and the word beside it together:
   `marker--live` with "Available", `marker--off` with "Unavailable". */
.marker--live { background-color: var(--live); }
.marker--off { background-color: var(--off); }

/* --------------------------------------------------------------------------
   5. Buttons
   --------------------------------------------------------------------------
   Square, mono, caps, widely tracked. No radius, no gradient, no shadow. The
   hover is a white fill wiping in from the left with the label inverting to
   black underneath it - one gesture, one pseudo-element, and it is the same
   gesture on both variants, so the two read as one control at two weights.
   -------------------------------------------------------------------------- */
.btn {
  position: relative;
  overflow: hidden;
  display: inline-flex;
  align-items: center;
  gap: 12px;
  font-family: var(--mono);
  font-size: 11.5px;
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  border: 1px solid transparent;
  border-radius: var(--r);
  padding: 16px 26px;
  line-height: 1;
  white-space: nowrap;
  transition: color 0.22s var(--ease), border-color 0.22s var(--ease),
    opacity 0.18s var(--ease);
}

.btn::before {
  content: "";
  position: absolute;
  inset: 0;
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform 0.34s var(--ease);
}

.btn:hover::before { transform: scaleX(1); }

/* The label has to ride above the fill. */
.btn > * { position: relative; z-index: 1; }

/* The solid one. White on black is the loudest thing this palette can do, so
   there is never more than one of these on a screen. */
.btn--primary {
  background-color: var(--fg);
  border-color: var(--fg);
  color: var(--base);
}

.btn--primary::before { background-color: var(--accent-hi); }
.btn--primary:hover { border-color: var(--accent-hi); }

/* Outlined. The border sits under full white at rest on purpose - a button
   already as bright as its own fill has nowhere to go on hover. */
.btn--ghost {
  border-color: rgba(255, 255, 255, 0.32);
  color: var(--fg);
}

.btn--ghost::before { background-color: var(--fg); }

.btn--ghost:hover {
  color: var(--base);
  border-color: var(--fg);
}

.btn--sm { padding: 11px 18px; font-size: 10.5px; letter-spacing: 0.14em; }

/* The arrow is the one glyph in the label that isn't tracked out - letter
   spacing applies after every character including the last, which would
   otherwise park the arrow a quarter of a space left of where it looks
   right. */
.btn__arrow {
  font-size: 13px;
  letter-spacing: 0;
  transition: transform 0.22s var(--ease);
}

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

.btn[disabled] { opacity: 0.4; cursor: not-allowed; }
/* --------------------------------------------------------------------------
   6. Lift
   --------------------------------------------------------------------------
   The hover shared by the three grids of choosable things: projects,
   capabilities and rates. A panel rises off its slot, its number takes the
   mark colour, and that's it - it marks the one you're reading rather than
   resizing it for effect.

   THE PART THAT MATTERS: the element that moves is never the element being
   hovered. Each panel is nested inside a grid item that stays put. If the
   hover target moved, the pointer would fall off its own bottom edge the
   moment it lifted, the hover would drop, the panel would fall back under the
   pointer, and it would strobe. That's why every one of these is two
   elements deep - don't flatten them.

   Movement is behind a hover query so a touch device never gets a panel stuck
   in the lifted state after a tap. Keyboard focus gets the same treatment
   without it. */
.panel {
  display: flex;
  flex-direction: column;
  width: 100%;
  overflow: hidden;
  background-color: var(--base);
  border: 1px solid var(--hairline-strong);
  border-radius: var(--r);
  transition: transform 0.3s var(--ease), box-shadow 0.3s var(--ease),
    background-color 0.3s var(--ease), border-color 0.3s var(--ease);
}

/* The stable outer half of the pair. */
.panel-slot { display: flex; min-width: 0; }

.panel-slot:focus-within .panel {
  background-color: var(--surface);
  border-color: var(--brand-dim);
}

@media (hover: hover) and (pointer: fine) {
  .panel-slot:hover .panel,
  .panel-slot:focus-within .panel {
    transform: translateY(-6px);
    background-color: var(--surface);
    border-color: var(--brand-dim);
    box-shadow: 0 20px 44px -26px rgba(0, 0, 0, 0.9);
  }
}

/* --------------------------------------------------------------------------
   7. Tags and links
   -------------------------------------------------------------------------- */
.tag {
  display: inline-flex;
  align-items: center;
  border: 1px solid var(--hairline);
  border-radius: 2px;
  padding: 5px 9px;
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--fg-2);
  white-space: nowrap;
}

.tag-row { display: flex; flex-wrap: wrap; gap: 8px; }

.ulink {
  position: relative;
  display: inline-flex;
  align-items: baseline;
  gap: 6px;
}

.ulink::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: -2px;
  height: 1px;
  background-color: currentColor;
  opacity: 0.28;
  transition: opacity 0.18s var(--ease), background-color 0.18s var(--ease);
}

.ulink:hover::after { opacity: 1; background-color: var(--brand); }

/* --------------------------------------------------------------------------
   7. Header / nav
   -------------------------------------------------------------------------- */
/* The bar sits over the hero so the first screen is uninterrupted, and picks
   up a plate once you've left it. It no longer hides itself on the way down -
   a nav that disappears is a nav you have to go looking for. Instead it
   carries a progress rule, so the bar also answers "how much of this is
   left". */
.header {
  position: sticky;
  top: 0;
  z-index: 50;
  transition:
    transform 0.34s var(--ease),
    background-color 0.25s var(--ease),
    box-shadow 0.25s var(--ease);
}

/* Off the top on the way down, back on the way up. Scrolling down is reading,
   and the bar is not part of what you are reading; scrolling up is looking for
   something, and the bar is usually the something. main.js adds and removes
   the class - see the header block there for when.

   translate rather than `top`, so it slides on the compositor and so the bar
   keeps its space in the layout: nothing below it moves when it goes. */
.header.is-hidden { transform: translate3d(0, -100%, 0); }

/* Never hidden while the mobile sheet is open or while a keyboard is inside
   the bar - in both cases the bar is the thing being used. */
.header.is-hidden:focus-within,
.nav-open .header.is-hidden { transform: none; }

/* A bar that slides is a bar that moves under you when a reduced-motion user
   scrolls. Keep it put. */
@media (prefers-reduced-motion: reduce) {
  .header { transition: background-color 0.25s var(--ease), box-shadow 0.25s var(--ease); }
  .header.is-hidden { transform: none; }
}

.header.is-docked {
  background-color: color-mix(in oklab, var(--base) 72%, transparent);
  backdrop-filter: blur(18px) saturate(140%);
  -webkit-backdrop-filter: blur(18px) saturate(140%);
  box-shadow: inset 0 -1px 0 var(--hairline);
}

/* Reading progress: one hairline pinned to the bottom edge of the bar, drawn
   only once the bar has a plate to sit on. */
.header__progress {
  position: absolute;
  left: 0; bottom: 0;
  height: 1px;
  width: 100%;
  transform-origin: 0 50%;
  transform: scaleX(var(--progress, 0));
  background-color: var(--brand);
  opacity: 0;
  transition: opacity 0.25s var(--ease);
}

.header.is-docked .header__progress { opacity: 0.9; }

.nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  height: 68px;
}

.brand {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  font-size: 15px;
  font-weight: 600;
  letter-spacing: -0.01em;
}

/* Initials in a solid square, in mono - a stamp, not a rounded app icon. Use
   one of three for the brand colour, and the reason the other two are allowed
   to exist: a logo is the one mark on a page that is permitted to be the only
   thing of its kind. */
.brand__mark {
  display: grid;
  place-items: center;
  width: 26px; height: 26px;
  background-color: var(--brand);
  color: #ffffff;
  font-family: var(--mono);
  font-size: 10.5px;
  font-weight: 500;
  letter-spacing: 0.02em;
}

.nav__desktop {
  display: none;
  align-items: center;
  gap: 28px;
}

.nav__links {
  display: flex;
  align-items: center;
  gap: 26px;
}

/* Tracked-out caps, white at rest rather than grey. The old bar set each item
   like a comment in source - `// work` with a superscript ordinal over it -
   and that was the busiest element on the page. The ordinals still exist; they
   are printed on the sections themselves, which is where a reader actually
   needs them.

   The rule underneath is the "you are here" signal: drawn faint on hover so
   you can see what you're about to click, drawn solid and in the one brand
   colour for the section you're currently in. Nothing else about the link
   changes, because nothing else needs to. */
.nav__link {
  position: relative;
  display: inline-block;
  padding-block: 6px;
  font-family: var(--mono);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--fg);
  transition: color 0.18s var(--ease);
}

.nav__link::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 1px;
  background-color: var(--fg);
  transform: scaleX(0);
  transform-origin: 0 50%;
  opacity: 0.5;
  transition: transform 0.22s var(--ease), opacity 0.22s var(--ease),
    background-color 0.22s var(--ease);
}

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

/* Use two: the section you are reading. */
.nav__link.is-active::after {
  transform: scaleX(1);
  opacity: 1;
  background-color: var(--brand);
}

@media (min-width: 1024px) {
  .nav__desktop { display: flex; }
  .nav__toggle { display: none; }
}

.nav__toggle {
  font-family: var(--mono);
  font-size: 11.5px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--fg-2);
  padding: 8px;
  margin-right: -8px;
}

/* Mobile menu */
.menu {
  position: fixed;
  inset: 0;
  z-index: 60;
  background-color: var(--base);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.18s var(--ease);
}

.menu.is-open { opacity: 1; pointer-events: auto; }

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

.menu__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  height: 68px;
  border-bottom: 1px solid var(--hairline);
}

.menu__body {
  display: flex;
  flex-direction: column;
  height: calc(100dvh - 68px);
  padding-block: 32px;
}

/* Each row is a ruled line carrying its section number, so the menu is the
   same index the page uses rather than a separate list of words. */
.menu__links {
  display: flex;
  flex-direction: column;
}

.menu__link {
  display: flex;
  align-items: baseline;
  gap: 18px;
  border-top: 1px solid var(--hairline);
  padding-block: 18px;
  font-size: clamp(26px, 7vw, 36px);
  font-weight: 600;
  letter-spacing: -0.028em;
  line-height: 1;
}

.menu__links li:last-child .menu__link { border-bottom: 1px solid var(--hairline); }

.menu__n {
  font-family: var(--mono);
  font-size: 11.5px;
  font-weight: 400;
  letter-spacing: 0.1em;
  color: var(--accent);
}

.menu__foot {
  margin-top: auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding-top: 32px;
}

.menu__foot .eyebrow { max-width: 20ch; line-height: 1.6; }

/* --------------------------------------------------------------------------
   8. Hero
   --------------------------------------------------------------------------
   One screen, one drawing, and the copy set against the left edge of it.

   What gets drawn is a sphere of service nodes with the links between them,
   turning on a tilted axis and lit from one side, so most of it is in shadow
   at any moment. It is the shape of the thing this site is about rather than
   a photograph of something else, and it is generated - see heroNetwork() in
   main.js - so there is no image to ship and nothing to go stale.
   -------------------------------------------------------------------------- */
.hero {
  position: relative;
  overflow: hidden;
  /* Slide up under the sticky bar so the first screen has no lid on it. */
  margin-top: calc(var(--header-h) * -1);
  min-height: 100svh;
  display: flex;
  align-items: center;
}

/* Narrow screens invert the composition.

   Side by side there are two columns and the copy is centred in its own; in
   one column there are no columns, and centring the copy left a band of dead
   black above it, the drawing sitting *behind* it, and more dead black under
   it. Both reference heroes solve this the same way and it is the right
   answer: the picture owns the top of the screen and the copy is anchored to
   the bottom, so the two are stacked rather than overlaid and the screen has
   one empty area instead of two.

   The script moves the sphere up to match - see measure() in main.js. */
@media (max-width: 900px) {
  .hero { align-items: flex-end; }
}

/* Older browsers with no svh support get the classic vh behaviour. */
@supports not (height: 100svh) {
  .hero { min-height: 100vh; }
}

/* The drawing fills the screen. Where the sphere sits inside it is decided by
   the script as a fraction of the canvas, not by a rule here, so it follows
   the breakpoint without a second set of numbers to keep in sync. */
.hero__canvas {
  position: absolute;
  inset: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  opacity: calc(1 - var(--hero-p) * 0.9);
}

/* Black poured in from the left, so the headline always has ground under it
   whatever the sphere happens to be doing behind it, plus a short fade at the
   bottom into the strip below. This is the whole trick that lets the copy and
   the drawing share one screen without a panel between them. */
.hero__veil {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background:
    linear-gradient(to right,
      var(--base) 0%,
      color-mix(in oklab, var(--base) 80%, transparent) 36%,
      transparent 64%),
    linear-gradient(to bottom, transparent 74%, var(--base) 100%);
}

/* Narrow screens: there is only one column, so the two cannot sit side by
   side and the drawing is the one that gives way. The script drops it low and
   shrinks it (see measure()), and this takes the top of it down far enough
   that no mesh line ever crosses the headline. A drawing behind type at 40%
   is atmosphere; at 100% it is just noise the reader has to see past. */
@media (max-width: 900px) {
  /* Brighter than it was. At 0.55 the sphere was atmosphere behind the copy;
     now that it has the top of the screen to itself and nothing is being read
     over it, it can be the picture it is. */
  .hero__canvas { opacity: calc((1 - var(--hero-p) * 0.9) * 0.8); }

  /* Read bottom-up: clear over the drawing at the top, then black poured in
     under it so the copy at the foot of the screen has solid ground. Same job
     as the left-to-right pour on desktop, turned ninety degrees, because on
     one column the copy is below the picture rather than beside it. */
  .hero__veil {
    background:
      linear-gradient(to bottom,
        color-mix(in oklab, var(--base) 55%, transparent) 0%,
        transparent 18%,
        transparent 38%,
        color-mix(in oklab, var(--base) 78%, transparent) 58%,
        var(--base) 72%);
  }
}

/* The copy leaves with the drawing rather than sitting still while it fades -
   the whole first screen lifts and goes, so scrolling down reads as leaving a
   place instead of sliding a panel off one. The scroll-driven fade lives here
   on the parent; the load-in animations live on the children, so the two
   never fight over the same opacity. */
.hero__inner {
  position: relative;
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  text-align: left;
  padding-top: calc(var(--header-h) + clamp(10px, 2vh, 36px));
  padding-bottom: clamp(96px, 12vh, 150px);
  width: 100%;
  /* NO max-width of its own. It inherits .shell's, same as every other block
     on the page, and that is deliberate.

     This was briefly widened to 1600 to walk the copy out toward the left edge
     on big monitors. It did that, and it also broke the one alignment on the
     page a visitor actually sees: the brand in the header sits on .shell, so
     the headline no longer started on the same left edge as "Felipe Ariza"
     above it, or as any section below it. A first screen where the two largest
     pieces of text are 120px out of line looks wrong long before anyone works
     out why.

     The right-heavy composition it was trying to fix is a real problem, but it
     is the drawing's problem: the fix is to bring the sphere in off the right
     edge, not to move the text away from everything it lines up with. That
     lives in measure() in main.js. */
  opacity: calc(1 - var(--hero-p) * 1.25);
  transform: translateY(calc(var(--hero-p) * -56px));
}

/* Load-in. Three lines arriving in reading order, close enough together that
   it reads as one movement - the page starting, not three things animating. */
.hero__meta,
.hero .display,
.hero__actions {
  animation: hero-in 0.7s var(--ease) both;
}

.hero .display { animation-delay: 0.08s; }
.hero__actions { animation-delay: 0.2s; }

@keyframes hero-in {
  from { opacity: 0; transform: translateY(18px); }
  to { opacity: 1; transform: none; }
}

/* Who and where, before what. A plain line of mono, no pill around it.
   Aligned to the top rather than the middle, because on a narrow screen this
   wraps to two lines and a marker floating between them reads as a bullet for
   neither. */
/* Neither reference has anything above the headline, and the honest reason
   this one keeps it is that "unavailable" is load-bearing for a freelancer -
   it answers the visitor's first question before they scroll. So it stays, but
   it steps back: a shade dimmer, so it is available to anyone looking for it
   and not competing with the line underneath. */
.hero__meta {
  display: inline-flex;
  align-items: flex-start;
  gap: 12px;
  font-family: var(--mono);
  font-size: 10.5px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--fg-3);
}

/* ---- The headline ----
   Set against the SpaceX hero rather than against the rest of this site, which
   is a deliberate exception and the reason it overrides three things .display
   has already decided.

   SIZE. .display runs to 7.4vw. Theirs is about 3.8vw - roughly half - and the
   restraint is most of the effect: a headline that fills the screen edge to
   edge is shouting, and a first screen that shouts is the thing that reads as
   a template. Smaller type with more black around it reads as confidence.

   LINE HEIGHT. .display sits at 0.92, which welds the lines into one block.
   Theirs is nearer 1.1, so MAKING / LIFE / MULTIPLANETARY are three separate
   words stacked with air between them rather than a wall of letters. That gap
   is what makes each line land on its own.

   MEASURE. Narrow on purpose. At 15ch this headline set four full-width
   lines; at 11ch it breaks into short stacked ones, which is the shape of the
   reference. text-wrap is off because `balance` fights a deliberately short
   measure - it evens the lines out again and undoes exactly what the narrow
   measure is for. */
.hero__headline {
  display: grid;
  align-items: start;
}

/* ---- The swap ----
   Out, then in, with a gap between - not a cross-fade.

   OUT is 240ms with no delay. IN is 300ms delayed by 240, so it starts at the
   exact moment the outgoing line finishes. There is never a frame with two
   sentences on screen at once, which is the whole point: the previous version
   overlapped them and on a phone, where the two wrap to different shapes, the
   overlap read as a rendering fault rather than as a transition.

   visibility rides along so a line that is not showing cannot be clicked,
   selected or tabbed into; it flips at the end of the out and at the start of
   the in, which is what the two 0s durations are doing. */
.hero__line {
  grid-area: 1 / 1;
  opacity: 0;
  visibility: hidden;
  transform: translateY(8px);
  transition:
    opacity 0.24s var(--ease),
    transform 0.24s var(--ease),
    visibility 0s linear 0.24s;
}

.hero__line.is-on {
  opacity: 1;
  visibility: visible;
  transform: none;
  transition:
    opacity 0.3s var(--ease) 0.24s,
    transform 0.3s var(--ease) 0.24s,
    visibility 0s linear 0.24s;
}

/* Asked for no motion, given no motion: the script picks one line and leaves
   it, so nothing here ever has to animate. */
@media (prefers-reduced-motion: reduce) {
  .hero__line,
  .hero__line.is-on { transition: none; }
}

/* Tighter than it was, top and bottom.

   The status line, the headline and the buttons are one block - who I am,
   what I do, what you can do about it - and they were spaced at 34 and 52,
   far enough apart that they read as three separate things that happened to
   be stacked. 22 and 34 hold them together while still ranking them. */
.hero .display {
  margin-top: 22px;
  /* Wide enough that the break in the markup is the only one that happens.
     At 11ch the browser added its own, and its own was bad: it broke after
     "SYSTEMS A", leaving an article stranded at the end of a line. */
  /* Wide enough that the break written into each sentence is the ONLY break
     that happens. At 22ch two of the five wrapped again on their own - one
     left the word FULL alone on a third line - and a sentence that breaks
     where nobody chose is the thing this measure exists to prevent. The
     longest line of the five is 24 characters, so 26 is the number. */
  max-width: 26ch;
  /* The floor is set by the LONGEST line of the five, not by what looks best
     on its own.

     All five sentences share one grid cell, so the block is as tall as the
     tallest of them and a short sentence leaves the difference as dead space
     above the buttons. Keeping every sentence to two lines is therefore not a
     nicety, it is what stops the hero changing shape.

     The longest line is "No kits. No free models." at 24 characters. At 28px
     that measures about 309px against the 327px a 375px phone has between its
     gutters; at 32px it measured 353 and wrapped, which is what turned that
     sentence into four lines and the block into a four-line box. */
  font-size: min(clamp(28px, 4.4vw, 64px), 11.5vh);
}

/* Below 360px the gutters eat enough of the line that 28px stops holding 24
   characters and the two longest sentences went to three and four lines
   again - the exact shape-changing this floor exists to prevent. 24px holds
   them on a 320px screen with about 15px to spare. */
@media (max-width: 360px) {
  .hero .display { font-size: 24px; }
  line-height: 1.08;
  text-wrap: normal;
}

/* The break is in the markup rather than left to the measure, because where a
   headline turns is a decision about meaning and the browser doesn't have the
   information to make it. "I write the systems / a project runs on" is two
   whole clauses; every other break splits one.

   It applies at every width. On a phone the first clause wraps again inside
   itself, which gives the three-line stack the reference hero has, and the
   second clause still starts its own line. The alternative - letting the
   browser reflow freely below 700px - put "SYSTEMS A PROJECT" on one line and
   ran it into the gutter.

   The nbsp between "a" and "project" in the markup is the other half of this:
   wherever the text happens to turn, an article never ends a line. */
.hero__br { display: inline; }

/* ---- The rotating headline ----
   Every sentence ships in the markup and they all occupy the same single grid
   cell, so the block is always as tall as the longest one and nothing below it
   ever moves when they swap. Without JS the first one is simply the one that
   is visible, which is why each sentence has to stand on its own.

   Inactive lines keep their space but are taken out of the accessibility tree
   by the aria-hidden the markup ships and the script maintains - otherwise a
   screen reader reads the headline as all five sentences run together. */
/* One sentence, so no stack and nothing to cross-fade. This used to be a grid
   with five lines in the same cell, each fading the last one out. */


.hero__actions {
  margin-top: 34px;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 14px;
}

/* On one column the two buttons wrapped onto separate lines at their own
   natural widths, which left a ragged left-hand stack of two different-sized
   boxes. Starlink's phone hero stacks them full width, and full width is also
   simply the better tap target. Closer to the headline too: at 52px on a
   screen this size the buttons read as a separate thing further down the page
   rather than as what you do about the line above them. */
@media (max-width: 700px) {
  .hero__actions {
    margin-top: 28px;
    align-self: stretch;
    flex-direction: column;
    align-items: stretch;
    gap: 10px;
  }

  .hero__actions .btn { justify-content: center; }
}

/* ---- The bottom rail ----
   A scroll cue on the left and a readout on the right, sharing one hairline
   across the foot of the screen. The readout is not decoration and it is not
   invented: it prints what the canvas next to it is actually doing this
   frame, read straight off the simulation. */
.hero__rail {
  position: absolute;
  left: 0; right: 0;
  bottom: 0;
  z-index: 2;
  /* Fades out the moment you start scrolling - once you're moving, the cue
     has done its job. */
  opacity: calc(0.9 - var(--hero-p) * 0.9);
  pointer-events: none;
}

.hero__rail-inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  border-top: 1px solid var(--hairline);
  padding-block: 16px;
}

.hero__cue {
  display: inline-flex;
  align-items: center;
  gap: 12px;
  /* The rail ignores the pointer so it never eats a click meant for the
     drawing; the one thing in it that is a link takes it back. */
  pointer-events: auto;
}

.hero__cue-line {
  width: 26px;
  height: 1px;
  background-color: var(--fg-3);
  transition: width 0.22s var(--ease), background-color 0.22s var(--ease);
}

/* The label sets its own colour, so it has to be told to brighten too. */
.hero__cue:hover .eyebrow { color: var(--fg); }
.hero__cue:hover .hero__cue-line { width: 40px; background-color: var(--fg); }

/* Sits the square on the first line rather than on the block. */
.hero__meta .marker { margin-top: 0.32em; }

@media (max-width: 700px) {
  /* It has to stay on one line, or it pushes the headline down by a whole
     line for no gain. */
  .hero__meta { font-size: 9px; letter-spacing: 0.1em; gap: 9px; }

  .hero__rail-inner { gap: 12px; }
  .hero__cue-line { width: 16px; }
}

/* Laptop screens: trim the breathing room so the hero still fits one screen. */
@media (max-height: 900px) {
  .hero__inner {
    padding-top: calc(var(--header-h) + clamp(8px, 1.5vh, 24px));
    padding-bottom: clamp(76px, 9vh, 112px);
  }
}

/* Landscape phones: the rail would collide with the buttons. */
@media (max-height: 620px) {
  .hero__rail { display: none; }
  .hero__inner {
    padding-top: calc(var(--header-h) + 8px);
    padding-bottom: 40px;
  }
}

/* --------------------------------------------------------------------------
   9. Stack strip
   --------------------------------------------------------------------------
   This was a marquee. A list that slides past you is a list you can't read at
   your own speed, so it is a table now: six groups, labelled, sorted, still.

   Three of those groups arrived when the capabilities section was folded in
   here: what I build now sits beside what I build it with, which is the same
   glance rather than a second screen. Two rows of three on a desktop, two
   columns on a tablet, one on a phone - six equal columns would set every list
   one word to a line. */
.stack {
  border-block: 1px solid var(--hairline);
  background-color: var(--surface);
}

.stack__inner {
  display: grid;
  gap: 24px;
  padding-block: 26px;
}

@media (min-width: 560px) {
  .stack__inner { grid-template-columns: repeat(2, 1fr); gap: 28px 32px; }
}

@media (min-width: 900px) {
  .stack__inner { grid-template-columns: repeat(3, 1fr); gap: 30px 32px; }
}

.stack__group {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.stack__items {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 14px;
  font-family: var(--mono);
  font-size: 12.5px;
  color: var(--fg-2);
}

.stack__items li { white-space: nowrap; }
/* --------------------------------------------------------------------------
   10. Proof
   --------------------------------------------------------------------------
   Four numbers, on one rule, between the hero and the work. Every figure here
   is repeated in a spec sheet further down - this band is a summary of the
   page, not a claim of its own, and that constraint is the whole reason it can
   be trusted. If you add a number here with nothing below it to back it up,
   you have turned it back into the marketing section it replaced.
   -------------------------------------------------------------------------- */
/* Shorter at the foot than a standard .section. The numbers and the focus
   label under them are one thought - "here is what shipped, here is what it
   was built in" - and 150px of black between them read as the page ending
   twice. */
/* Much shallower at the top than a standard .section.

   The band above this one is the tech list, which has its own padding and a
   hairline under it. Stacking .section's 150px on top of that put roughly a
   fifth of a screen of black between two bands that are the same thought -
   here is what I work with, here is what it has produced - and read as the
   page having ended and restarted.

   They are one region now: tools, then what the tools did. */
.proof { padding-block: clamp(40px, 4vw, 58px) clamp(40px, 4.5vw, 64px); }

.proof__lead {
  display: inline-flex;
  align-items: center;
  gap: 12px;
  color: var(--fg-2);
}

.proof__grid {
  margin-top: 40px;
  display: grid;
  gap: 0;
  border-top: 1px solid var(--hairline-strong);
}

/* Rows on a phone, columns from tablet up. The divider has to move with it -
   between rows it is a top border, between columns a left border - which is
   why the two cases are written out rather than shared. */
.proof__item {
  border-bottom: 1px solid var(--hairline);
  padding-block: 28px;
}

@media (min-width: 720px) {
  .proof__grid { grid-template-columns: repeat(4, 1fr); }

  .proof__item {
    border-bottom: 0;
    border-left: 1px solid var(--hairline);
    padding: 30px 26px 4px;
  }

  .proof__item:first-child { border-left: 0; padding-left: 0; }
}

.proof__n {
  font-size: clamp(46px, 6vw, 76px);
  font-weight: 700;
  font-variation-settings: "wdth" 78;
  line-height: 0.9;
  letter-spacing: 0.005em;
  color: var(--fg);
}

.proof__label {
  margin-top: 16px;
  max-width: 26ch;
  font-size: 13.5px;
  line-height: 1.5;
  color: var(--fg-2);
}

/* The project name inside the caption is the pointer to where the number can
   be checked, so it takes the one colour on the page that means "signal". */
.proof__label b { color: var(--brand); font-weight: 500; }

/* The whole caption is the target, not just the project name in it - a 13.5px
   run of text with two coloured words in the middle is a poor thing to ask
   anyone to hit, and on a phone it is a miss. */
.proof__link {
  display: block;
  color: inherit;
  transition: color 0.18s var(--ease);
}

.proof__link:hover { color: var(--fg); }

/* The arrow is the only part that is decoration, so it is the only part that
   is hidden until you go looking: out of the flow on its own line, held at
   zero opacity, nudged into place on hover. It never moves the text. */
.proof__cue {
  display: inline-block;
  margin-left: 6px;
  opacity: 0;
  transform: translateX(-4px);
  color: var(--brand);
  transition: opacity 0.18s var(--ease), transform 0.18s var(--ease);
}

.proof__link:hover .proof__cue,
.proof__link:focus-visible .proof__cue {
  opacity: 1;
  transform: none;
}

/* Touch has no hover, so there the arrow is simply always drawn - a link
   nobody can tell is a link is worse than a permanent arrow. */
@media (hover: none) {
  .proof__cue { opacity: 1; transform: none; }
}

@media (prefers-reduced-motion: reduce) {
  .proof__cue { transition: opacity 0.18s var(--ease); transform: none; }
}


/* --------------------------------------------------------------------------
   10b. Focus
   --------------------------------------------------------------------------
   The band between the numbers and the slides, and the only thing on the page
   that names the platform. It is set at heading weight rather than as a note,
   because a reader scanning down should not have to read it to notice it -
   the slides underneath are all one platform, and this is the caption for all
   of them at once.

   Shallower than a full .section: this is a label on what follows, and the
   standard 150px would open a gap that reads as the end of the page rather
   than as the introduction to the next part of it.
   -------------------------------------------------------------------------- */
.focus {
  padding-block: clamp(44px, 5vw, 72px) clamp(52px, 6.5vw, 92px);
  border-top: 1px solid var(--hairline);
}

.focus__label {
  display: inline-flex;
  align-items: center;
  gap: 14px;
  /* Well above .eyebrow's 11px, and deliberately louder than the line under
     it. This label is the point of the band: it tells you that what follows
     is the current focus, and the platform name is the answer to it. When it
     was set at 13px under a 60px headline, the headline read as a slogan and
     the label as its footnote - which is the relationship backwards. */
  font-size: clamp(15px, 1.5vw, 19px);
  letter-spacing: 0.2em;
  color: var(--fg);
}

/* The marker scales with the label, or it turns into a speck next to it. */
.focus__label .marker { width: 9px; height: 9px; }

/* Below the .h2 scale on purpose. It is the answer to the label above it, not
   a section heading of its own - at full heading size it competed with the
   project titles in the slides underneath and read like a third headline on a
   page that already has two. Smaller, it stays a statement of fact, and the
   label above it does the announcing. The language carries the page's one
   emphasis mark, .hl, because "Roblox" tells you the platform and "Luau" is
   the part that says this is engineering. */
.focus__value {
  margin-top: 18px;
  font-size: clamp(26px, 3.1vw, 38px);
  font-weight: 700;
  font-variation-settings: "wdth" 80;
  text-transform: uppercase;
  letter-spacing: 0.004em;
  line-height: 0.96;
  max-width: 22ch;
  text-wrap: balance;
  color: var(--fg);
}

/* The mark, set for this line specifically.

   .hl positions itself off the bottom of its inline box, and this line has the
   tightest line-height on the page (0.96), so the shared offset landed the bar
   against the letters and turned a drawn mark into what looked like a link
   underline. A negative offset pushes it clear of the baseline instead; it can
   be negative here because this is the last line of the block, so there is
   nothing underneath for it to collide with.

   The weight stays at the hero's 3px rather than scaling down with the type.
   At 38px a 2px hairline reads as text-decoration; 3px reads as the same mark
   the hero draws, which is the point of having one mark. */
.focus__value .hl::after {
  height: 3px;
  bottom: -0.06em;
}

.focus__note {
  margin-top: 26px;
  max-width: 44ch;
  font-size: 16.5px;
}


/* --------------------------------------------------------------------------
   11. Slides
   --------------------------------------------------------------------------
   A project gets a screen, not a card.

   This replaced a two-up grid of panels, and the argument is the one that
   killed the carousel before it: a card gives four months of work the same
   400px as the paragraph beside it, and a reader scrolls past a row of cards
   without ever entering one. A slide has to be scrolled THROUGH.

   The picture is the subject here, not a texture behind the subject. That is
   the whole reason the scrim is as light as it is: the copy sits in the top
   left and only that corner is darkened, so the rest of the frame is the
   photograph at full strength. If a headline is ever hard to read on one of
   these, the fix is a better-chosen screenshot, not a heavier veil - a veil
   strong enough to rescue any image has already hidden the image.

   ---- How the parallax works ----

   The media layer is taller than the slide that contains it (120%, hung 10%
   above the top), and JS writes --p onto each slide: -1 when the slide is
   about to enter from the bottom, 0 when it is centred, +1 as it leaves the
   top. The CSS turns that into a translate. So the picture drifts against the
   page while the copy travels with it.

   This is deliberately NOT the `position: fixed` + `clip-path` trick, which is
   free but pins the layer to the viewport - and a viewport-fixed layer jumps
   every time a mobile browser shows or hides its address bar, which is why
   the effect had to be switched off on phones and why they looked dead. A
   transform is composited, costs one rAF-throttled read, and behaves
   identically on every device. One mechanism, everywhere.
   -------------------------------------------------------------------------- */
.slide {
  position: relative;
  overflow: hidden;
  min-height: 100svh;
  display: flex;
  /* Centred in the screen, not pinned to the top of it.

     Top-aligned, the copy ended a third of the way down and left two thirds of
     empty picture under it, which reads as something that failed to load rather
     than as space anybody chose. Centred, the same emptiness is a margin: equal
     above and below, obviously deliberate. */
  align-items: center;
  border-top: 1px solid var(--hairline);
  padding-top: calc(var(--header-h) + clamp(24px, 4vw, 48px));
  padding-bottom: clamp(48px, 8vw, 110px);
}

@supports not (height: 100svh) {
  .slide { min-height: 100vh; }
}

/* Oversized on purpose - see the parallax note above. The extra 20% is the
   travel budget; --p never drives it past 10%, so an edge can't come into
   frame however fast you scroll. */
.slide__media {
  position: absolute;
  left: 0;
  right: 0;
  top: -10%;
  height: 120%;
  z-index: 0;
  background-repeat: no-repeat;
  background-position: center;
  background-size: cover;
  /* Two things at once, both read off the slide's position.

     --p slides it, which is the parallax. --pa is how far the slide is from
     centred regardless of direction, and it drives the arrival: the picture
     comes in slightly oversized and dim, settles to its true size and full
     brightness as the slide reaches the middle of the screen, and backs off
     again on the way out. That is what makes scrolling into one of these feel
     like arriving somewhere rather than like a background scrolling past.

     Both are composited properties, so this is still one paint. */
  transform:
    translate3d(0, calc(var(--p, 0) * 7%), 0)
    scale(calc(1 + var(--pa, 0) * 0.06));
  filter: brightness(calc(1 - var(--pa, 0) * 0.4));
  will-change: transform, filter;
}

/* The drawn stand-ins, in the same neutral scale as everything else. A slide
   with no screenshot yet still looks like part of the page rather than a hole
   in it, and these stay underneath once the real photo is in. */
.slide__media--gentes    { background-image: url("../img/ph-gentes-in-armis.svg"); }
.slide__media--area23    { background-image: url("../img/ph-area-23.svg"); }
.slide__media--workspace { background-image: url("../img/ph-workspace.svg"); }

.slide__img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Ground under the type and nothing more. A radial poured into the top-left
   corner where the copy actually is, plus a whisper across the whole frame so
   a blown-out sky can't take the headline with it. Everything below and to the
   right of the copy is the picture, untouched. */
.slide__veil {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background:
    radial-gradient(125% 100% at 0% 0%,
      rgba(0, 0, 0, 0.9) 0%,
      rgba(0, 0, 0, 0.68) 30%,
      rgba(0, 0, 0, 0.3) 56%,
      transparent 78%),
    linear-gradient(to bottom, rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0.16) 42%, transparent 72%);
}

/* The intro and the spec sheet are stacked in the SAME grid cell, and pressing
   Read more cross-fades between them.

   This is the fix for the sheet that opened smoothly and snapped shut, and it
   fixes it by removing the thing that was being animated. Every previous
   attempt grew and shrank the slide - first `grid-template-rows: 0fr <-> 1fr`,
   then an explicit height in pixels - and animating the height of a section
   that is already a full screen tall means the whole page below it moves too.
   Chrome would not interpolate the way back down reliably, and even when it
   did, the reflow was the jolt.

   Nothing here changes height at all. The cell is as tall as the taller of its
   two children and stays that way whichever one is showing, so opening and
   closing are pure opacity and transform: composited, symmetrical, and with no
   layout for the browser to give up on. It cannot snap, because there is
   nothing left to snap.

   It also answers the empty-space complaint directly: the sheet appears where
   the title was, in the middle of the screen, instead of hanging below it. */
.slide__inner {
  position: relative;
  z-index: 2;
  width: 100%;
  display: grid;
  align-items: center;
}

.slide__intro,
.slide__detail {
  grid-area: 1 / 1;
  transition:
    opacity 0.45s var(--ease),
    transform 0.45s var(--ease),
    visibility 0s linear 0.45s;
}

/* Visibility as well as opacity: a pane at zero opacity is still in the
   accessibility tree and still focusable, so a keyboard would tab into the spec
   sheet of a project whose sheet is shut. */
.slide__intro {
  opacity: 1;
  transform: none;
  visibility: visible;
  transition-delay: 0s, 0s, 0s;
}

.slide__detail {
  opacity: 0;
  transform: translateY(26px);
  visibility: hidden;
  pointer-events: none;
}

.slide.is-open .slide__intro {
  opacity: 0;
  transform: translateY(-32px);
  visibility: hidden;
  pointer-events: none;
  transition-delay: 0s, 0s, 0.45s;
}

.slide.is-open .slide__detail {
  opacity: 1;
  transform: none;
  visibility: visible;
  pointer-events: auto;
  transition-delay: 0.08s, 0.08s, 0s;
}

@keyframes slide-swap {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: none; }
}

/* With no JS nothing toggles, so the two simply stack down the page and both
   stay readable. */
.no-js .slide__inner { display: block; }

.no-js .slide__detail {
  opacity: 1;
  transform: none;
  visibility: visible;
  pointer-events: auto;
  margin-top: 28px;
}

/* ---- The copy in the corner ---- */
/* Brighter than a plain .eyebrow, and not optional. Elsewhere this label sits
   on flat black, where --fg-3 is a deliberate step down. Here it sits on a
   photograph whose top corner is a lit sky, and --fg-3 against that is a grey
   line on a grey sky. The veil below does the other half of the work; this is
   the half that survives whatever picture goes in next. */
.slide__eyebrow {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px 14px;
  color: var(--fg-2);
  /* A tight shadow, not a glow - enough to keep the strokes readable where
     the sky behind them is brightest, invisible where it is dark. */
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.85);
}

.slide__tick {
  width: 26px;
  height: 1px;
  background-color: rgba(255, 255, 255, 0.45);
}

/* Sized off the viewport rather than off a fixed step: this is a title over a
   photograph, and it has to hold the frame at whatever width the frame is. */
.slide__title {
  margin-top: 22px;
  /* SERVER-AUTHORITATIVE is one word to a line breaker. */
  overflow-wrap: break-word;
  font-size: clamp(38px, 7vw, 96px);
  max-width: 16ch;
}

.slide__title--sm { font-size: clamp(32px, 4.4vw, 58px); max-width: 20ch; }

.slide__lede {
  margin-top: 20px;
  font-size: 17px;
  max-width: 46ch;
}

.slide__actions { margin-top: 30px; }

/* No flipping arrow any more. It made sense when one button sat still and
   changed meaning; there are two now - Read more in the intro, Close in the
   sheet - and each is only ever on screen in the state its own glyph is true
   for. A rotation would have turned the Close arrow upside down. */
.slide.is-open .btn--ghost { border-color: var(--fg); }

/* The sheet is the one thing on a slide that gets a surface under it. Over a
   photograph, a spec table set in hairlines alone is unreadable. No
   backdrop-filter: at 82% black over a picture this dark the blur was not
   earning the compositor work it costs. */
.slide__detail-inner {
  max-width: 720px;
  padding: clamp(20px, 2.4vw, 30px);
  border: 1px solid var(--hairline-strong);
  background-color: rgba(0, 0, 0, 0.82);
}

.slide__detail-close { margin-top: 26px; }

.slide__detail-inner .spec { margin-top: 24px; }

/* ---- The code slide ----
   Copy on the left, the sample on the right, because here the sample is the
   argument rather than an illustration of it. */
.slide--code { align-items: center; }

.slide__veil--code {
  background:
    linear-gradient(to right,
      rgba(0, 0, 0, 0.88) 0%,
      rgba(0, 0, 0, 0.62) 45%,
      rgba(0, 0, 0, 0.42) 100%);
}

.slide__inner--code {
  display: grid;
  gap: 40px;
  /* Not 1fr: a grid column will not go below the width of its widest child
     unless told it may, and the widest child here is a <pre>. Without this the
     code sample sets the width of the whole slide and the headline next to it
     gets clipped on a phone. .code__body scrolls itself instead. */
  grid-template-columns: minmax(0, 1fr);
}

@media (min-width: 1020px) {
  .slide__inner--code {
    grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
    gap: 56px;
    align-items: center;
  }
}

/* Everywhere else on the site a figure is set straight onto the page, so its
   caption sits on the page margin and needs no inset of its own. Here the
   figure has a box drawn round it, and a caption hard against the inside of
   that box reads as a mistake. The padding is the figure's own frame. */
.slide__fig {
  border: 1px solid var(--hairline-strong);
  background-color: rgba(0, 0, 0, 0.68);
  padding: 16px 18px 18px;
  backdrop-filter: blur(14px) saturate(130%);
  -webkit-backdrop-filter: blur(14px) saturate(130%);
}

/* The sample inside already sits on the figure's own ground, so it drops its
   second border and its second background and keeps only the left rule that
   marks it as code. Two nested boxes with two backgrounds was one box too
   many. */
.slide__fig .code__body {
  border: 0;
  border-left: 2px solid var(--hairline-strong);
  background-color: transparent;
  padding: 2px 0 2px 16px;
}

/* The sample types itself in, a line at a time.

   Of everything on the page this is the one block where an entrance means
   something rather than just being movement: the argument it is making is
   about the order things happen in - the client asks, then the server checks,
   then the server decides - and reading it top to bottom at a tempo is the
   argument. Every other reveal on this site is a plain fade for exactly that
   reason: if they all did this, this one would stop saying anything.

   .slide__fig carries `reveal`, so the frame arrives first and the lines
   follow into it. The delays start after the frame has landed. Because the
   reveal observer now re-arms (see main.js), this replays every time the slide
   comes back, which is the point of it. */
.slide__fig .code__ln {
  opacity: 0;
  transform: translateX(-10px);
  transition:
    opacity 0.34s var(--ease),
    transform 0.34s var(--ease);
}

.slide__fig.is-in .code__ln { opacity: 1; transform: none; }

.slide__fig.is-in .code__ln:nth-child(1)  { transition-delay: 0.16s; }
.slide__fig.is-in .code__ln:nth-child(2)  { transition-delay: 0.22s; }
.slide__fig.is-in .code__ln:nth-child(3)  { transition-delay: 0.28s; }
.slide__fig.is-in .code__ln:nth-child(4)  { transition-delay: 0.34s; }
.slide__fig.is-in .code__ln:nth-child(5)  { transition-delay: 0.40s; }
.slide__fig.is-in .code__ln:nth-child(6)  { transition-delay: 0.46s; }
.slide__fig.is-in .code__ln:nth-child(7)  { transition-delay: 0.52s; }
.slide__fig.is-in .code__ln:nth-child(8)  { transition-delay: 0.58s; }
.slide__fig.is-in .code__ln:nth-child(9)  { transition-delay: 0.64s; }
.slide__fig.is-in .code__ln:nth-child(10) { transition-delay: 0.70s; }
.slide__fig.is-in .code__ln:nth-child(11) { transition-delay: 0.76s; }
.slide__fig.is-in .code__ln:nth-child(12) { transition-delay: 0.82s; }
.slide__fig.is-in .code__ln:nth-child(13) { transition-delay: 0.88s; }
.slide__fig.is-in .code__ln:nth-child(n + 14) { transition-delay: 0.94s; }

/* A block of text that assembles itself line by line is precisely the kind of
   thing somebody turns this setting on to stop. The sample just appears. */
@media (prefers-reduced-motion: reduce) {
  .slide__fig .code__ln {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

/* A dash rather than a bullet dot: it reads as a line in a spec. */
.work__points {
  display: grid;
  gap: 10px;
  list-style: none;
  padding: 0;
}

.work__point {
  display: flex;
  gap: 14px;
  font-size: 15px;
  color: var(--fg-2);
  line-height: 1.55;
}

.work__point::before {
  content: "";
  margin-top: 12px;
  width: 8px;
  height: 1px;
  background-color: var(--accent);
  flex-shrink: 0;
}

/* ---- Phones ----
   A landscape photograph inside a portrait slide gets cropped to a narrow
   vertical strip through its middle, which is how a full-screen slide ends up
   as a dark band with the subject cut out of it and 400px of nothing above the
   text. So on a phone the picture stops being a backdrop and becomes a band:
   full width, its own aspect ratio, the whole frame visible, with the copy
   underneath it on the page ground where it is simply easier to read.

   The parallax stays - the media layer is still oversized and still driven by
   --p - so the image moves inside its band as you scroll. */
@media (max-width: 900px) {
  .slide {
    display: block;
    min-height: 0;
    padding-top: 0;
    padding-bottom: clamp(40px, 9vw, 64px);
  }

  .slide__media {
    position: relative;
    top: auto;
    left: auto;
    right: auto;
    /* Wider than tall, but nowhere near 16:9 - a phone-width 16:9 band is a
       letterbox slot. This keeps enough height to read the picture as a
       picture. */
    aspect-ratio: 4 / 3;
    height: auto;
    /* The band itself is the clip now, and nothing is transformed: scaling the
       band made it overhang its own box, and since the veil is only as tall as
       the box, the overhang showed up under it as a hard cut across the
       photograph. Here the layer stays put and the picture moves INSIDE it -
       the background by its position, the <img> by a transform this box
       clips - so there is no overhang to leak. */
    overflow: hidden;
    transform: none;
    background-position: center calc(50% + var(--p, 0) * 5%);
  }

  .slide__img {
    transform: scale(1.14) translate3d(0, calc(var(--p, 0) * 4%), 0);
  }

  /* Over the band only, fading into the page at its foot so the photograph
     joins the ground instead of ending on a hard edge. */
  .slide__veil {
    inset: 0 0 auto 0;
    aspect-ratio: 4 / 3;
    background:
      linear-gradient(to bottom,
        rgba(0, 0, 0, 0.34) 0%,
        rgba(0, 0, 0, 0.1) 30%,
        rgba(0, 0, 0, 0.45) 78%,
        var(--base) 100%);
  }

  .slide__inner {
    margin-top: clamp(22px, 6vw, 34px);
    /* Not the overlaid grid it is on a desktop.

       Stacking the two panes in one cell means the cell is always as tall as
       the taller of them, which on a full-height desktop slide costs nothing -
       there was empty screen there anyway. On a phone the slide is only as tall
       as its content, so the same trick added about 430px of dead space under
       the intro and made these slides 1038px instead of 609px. Which is the
       exact complaint that started all of this, just moved to a smaller screen.

       So on a phone the panes swap outright: the one that is not showing is
       display:none and takes no room. The height changes instantly and nothing
       animates it, which is also why there is nothing here that can snap. */
    display: block;
  }

  .slide__intro,
  .slide__detail {
    transition: none;
  }

  .slide__detail { display: none; }
  .slide.is-open .slide__intro { display: none; }

  .slide.is-open .slide__detail {
    display: block;
    opacity: 1;
    visibility: visible;
    transform: none;
    pointer-events: auto;
    /* An animation rather than a transition, because the pane it is replacing
       has already been removed from the layout by the time this runs. It fades
       the new pane in without anything reflowing. */
    animation: slide-swap 0.3s var(--ease);
  }

  /* "01 - SELECTED WORK - RELEASING SOON" was taking three lines on a phone
     and leaving the little rules stranded at the end of them. The rules are
     the first thing to go: they are separators, and a line that has already
     wrapped is separated well enough. */
  .slide__tick { display: none; }

  .slide__eyebrow {
    gap: 4px 10px;
    font-size: 10px;
    letter-spacing: 0.08em;
  }

  .slide__title { max-width: 100%; }

  /* The code slide has no room for a band AND a code sample on a phone, so it
     keeps the picture as a backdrop and just leans on a heavier scrim. */
  .slide--code { display: flex; padding-top: clamp(56px, 14vw, 88px); }

  .slide--code .slide__media {
    position: absolute;
    top: -10%;
    left: 0;
    right: 0;
    height: 120%;
    aspect-ratio: auto;
    transform: translate3d(0, calc(var(--p, 0) * 6%), 0);
  }

  .slide--code .slide__veil {
    inset: 0;
    aspect-ratio: auto;
    background: linear-gradient(to bottom, rgba(0, 0, 0, 0.82), rgba(0, 0, 0, 0.72));
  }

  .slide--code .slide__inner { margin-top: 0; }
}

/* A drifting backdrop is exactly what this setting is asking you to stop
   doing, so --p is left at 0 and the picture sits still. */
@media (prefers-reduced-motion: reduce) {
  .slide__media,
  .slide--code .slide__media {
    transform: none;
    /* The arrival dimming goes with the movement. A picture that brightens as
       you reach it is still a thing changing under a reader who asked for
       nothing to change. */
    filter: none;
  }

  .slide__detail-inner { transition: none; }
}

@media (max-width: 900px) and (prefers-reduced-motion: reduce) {
  .slide__media { transform: scale(1.12); }
}

/* --------------------------------------------------------------------------
   12. Capabilities grid
   -------------------------------------------------------------------------- */
/* Four panels on a shared field. The gap is tight enough that they still read
   as one block rather than four floating cards, and wide enough that a panel
   can lift without touching its neighbour. */
.bento {
  margin-top: 56px;
  display: grid;
  grid-template-columns: 1fr;
  gap: 14px;
}

/* Every cell opens on the same line - a mono label at the same height, then
   the heading - which is what keeps a mixed grid from looking shuffled.
   Grid items also default to a min-width of their min-content, which lets a
   wide code block push the column past the viewport. Pin it to zero so the
   block scrolls inside its own box instead. */
.bento__cell { min-width: 0; position: relative; }

.bento__inner { padding: clamp(24px, 3vw, 34px); }

/* A lifted panel has to sit over its neighbours, not under them. */
.bento__cell:hover,
.bento__cell:focus-within { z-index: 2; }

.bento__cell:hover .bento__n,
.bento__cell:focus-within .bento__n { color: var(--brand); }

@media (min-width: 860px) {
  .bento { grid-template-columns: repeat(12, 1fr); }
  .bento__cell--lg { grid-column: span 7; }
  .bento__cell--md { grid-column: span 5; }
  .bento__cell--full { grid-column: span 12; }
}

/* Numbered like the sections, because that's what these are: an index of what
   I write, in the order it matters. The statement cells carry a label in the
   same slot so every cell in the table starts on the same line. */
.bento__n {
  font-family: var(--mono);
  font-size: 11.5px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--fg-3);
  margin-bottom: 18px;
  transition: color 0.24s var(--ease);
}

.bento__cell .h3 { margin-top: 0; }
.bento__cell .lede { margin-top: 12px; font-size: 15px; max-width: 52ch; }

/* The two statement cells. One is filled solid with the mark colour and one
   stays dark - the pair reads as a set that way, and the filled one is the
   single block of colour in this half of the page, which is what stops a
   column of hairline panels going flat. */
.bento__cell--note .bento__n { color: var(--brand); }

.bento__cell--fill .panel {
  background-color: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}

/* Dark on orange, at the weights that clear contrast on it. */
.bento__cell--fill .bento__n { color: rgba(0, 0, 0, 0.58); }
.bento__cell--fill .note__aside { color: rgba(0, 0, 0, 0.78); }

/* Without this the shared lift would repaint it in --surface and the colour
   would vanish the moment you pointed at it. */
.bento__cell--fill:hover .panel,
.bento__cell--fill:focus-within .panel {
  background-color: var(--accent-hi);
  border-color: var(--accent-hi);
}

/* Same reasoning as the statement: a sentence someone is saying. */
.note__body {
  font-size: clamp(20px, 2.2vw, 27px);
  font-weight: 600;
  letter-spacing: -0.022em;
  line-height: 1.2;
  text-wrap: balance;
}

.note__aside {
  margin-top: 16px;
  font-size: 15px;
  color: var(--fg-2);
  line-height: 1.6;
  max-width: 46ch;
}

/* --------------------------------------------------------------------------
   13. Spec table
   --------------------------------------------------------------------------
   Label / value rows in mono. Facts only - a row that can't be filled in
   truthfully gets deleted, not padded. */
.spec {
  margin-top: 26px;
  border-top: 1px solid var(--hairline);
}

.spec__row {
  display: grid;
  grid-template-columns: 88px 1fr;
  gap: 16px;
  padding-block: 11px;
  border-bottom: 1px solid var(--hairline);
  font-family: var(--mono);
  font-size: 12px;
  line-height: 1.55;
}

.spec__key {
  color: var(--fg-3);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  font-size: 10.5px;
  padding-top: 2px;
}

/* The values are the payload, so the confirmed ones are the only thing in the
   table that takes the mark colour - the way a highlighter treats a literal
   in source. Keys and separators stay dim. */
.spec__val { color: var(--fg-2); }
.spec__val b { color: var(--brand); font-weight: 500; }

@media (max-width: 420px) {
  .spec__row { grid-template-columns: 1fr; gap: 5px; }
}

/* --------------------------------------------------------------------------
   14. Code figure
   --------------------------------------------------------------------------
   Used once, for the one thing on this page that's easier to read as code
   than as a sentence. Captioned like a figure in documentation - no fake
   window chrome, no traffic lights - and the highlighting is nearly
   monochrome so it can't turn into decoration. */
.fig { margin-top: 26px; }

.fig__cap {
  display: flex;
  align-items: center;
  gap: 10px;
  font-family: var(--mono);
  font-size: 10.5px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--fg-3);
  padding-bottom: 10px;
}

.fig__cap::before {
  content: "";
  width: 8px; height: 1px;
  background-color: var(--brand);
}

.code__body {
  margin: 0;
  padding: 16px 18px;
  border: 1px solid var(--hairline);
  border-left: 2px solid var(--hairline-strong);
  border-radius: 0 var(--r) var(--r) 0;
  background-color: var(--surface);
  overflow-x: auto;
  font-family: var(--mono);
  font-size: 12px;
  line-height: 1.75;
  color: var(--fg-2);
  tab-size: 2;
}

.code__body code { font: inherit; }
.code__ln { display: block; white-space: pre; }

@media (max-width: 560px) {
  .code__body { font-size: 11px; padding: 14px; }
}

.code__cm { color: var(--fg-3); }
.code__kw { color: var(--fg); font-weight: 500; }
.code__fn { color: var(--brand); }
/* --------------------------------------------------------------------------
   15. Facts
   --------------------------------------------------------------------------
   What About is now. It used to be three paragraphs and a sidebar; the
   paragraphs were about me rather than about the work, so the sidebar is all
   that is left. Two columns of rows on a wide screen, because eight rows in a
   single column is a list you scroll past rather than read.
   -------------------------------------------------------------------------- */
.facts {
  margin-top: 48px;
  display: grid;
  gap: 0 56px;
  border-top: 1px solid var(--hairline-strong);
}

@media (min-width: 800px) {
  .facts { grid-template-columns: repeat(2, 1fr); }
}

.facts__row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 20px;
  border-bottom: 1px solid var(--hairline);
  padding-block: 15px;
}

.facts__row dt {
  font-family: var(--mono);
  font-size: 10.5px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--fg-3);
  white-space: nowrap;
}

.facts__row dd { font-size: 14.5px; text-align: right; color: var(--fg); }


/* --------------------------------------------------------------------------
   16. Closing screen
   --------------------------------------------------------------------------
   The quote and the address, on one screen, as the last thing on the page.

   These were two full-height sections and it was a mistake: you scrolled past
   a line with nothing under it to reach an email with nothing over it, and
   both halves read as filler because neither was doing anything for the other.
   Together they are a closing statement - a line, a rule, and where to write.

   The rule between them is the cooldown, filling over the five seconds to the
   next line, so the swap is something you can see coming rather than something
   that happens at you mid-read. It also separates the two halves, which is why
   this section draws no hairline of its own.
   -------------------------------------------------------------------------- */
/* The full-screen minimum is gone.

   It was right when this section held a rotating line at display scale, a
   progress rule and the address - that filled a screen. The line is now a
   footnote, so the same 100svh was reserving a whole screen for about 400px of
   content and padding the difference with black at both ends. A section should
   be as tall as what is in it. */
.closing {
  position: relative;
  overflow: hidden;
  border-top: 1px solid var(--hairline);
  padding-block: clamp(40px, 4vw, 60px) clamp(40px, 4vw, 56px);
}

/* One soft source of light behind the line, so the screen is not a flat black
   field with type on it. Nothing else in here draws anything. */
.closing__glow {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background:
    radial-gradient(58% 46% at 50% 34%,
      rgba(255, 255, 255, 0.07) 0%,
      rgba(255, 255, 255, 0.022) 42%,
      transparent 72%);
}

.closing__inner {
  position: relative;
  z-index: 1;
  width: 100%;
}

/* ---- The run-up ----
   One hairline, drawn straight down into the eyebrow under it.

   It is doing the job the empty space was failing to do: this is the last
   screen, the address is the point of it, and a vertical rule pointing at
   something is the plainest way to say "down here" without a word or an
   arrow. The gradient means it starts as nothing and arrives - a bar that
   simply appears reads as a border that has been left on by accident.

   It grows from nothing on the way in, which is the one animation in this
   section and happens exactly once. .reveal handles the trigger. */
.closing__lead {
  display: block;
  width: 1px;
  height: clamp(40px, 4.5vw, 64px);
  margin: 0 auto clamp(22px, 2.4vw, 30px);
  background: linear-gradient(to bottom, transparent, var(--fg-3));
  transform-origin: 50% 0;
  transform: scaleY(0);
  transition: transform 0.7s var(--ease) 0.1s;
}

.closing__lead.is-in { transform: scaleY(1); }

@media (prefers-reduced-motion: reduce) {
  .closing__lead { transform: none; transition: none; }
}

/* ---- The address ---- */
.closing__eyebrow {
  display: flex;
  justify-content: center;
  margin-bottom: 18px;
}

/* The mailto and the disclosure below it keep their own rules from section 17;
   all this does is stop them growing to the full 1280px of the shell, because
   a centred line above a full-width box reads as two unrelated things. */
.closing__contact > .mailto,
.closing__contact > .disclose {
  max-width: 720px;
  margin-inline: auto;
}

.closing__contact > .mailto { margin-top: 0; }

/* ---- The note ----
   All that is left of the quote band: one sentence, at note size, under the
   address instead of over it. It is deliberately quieter than everything
   around it - the point of the last screen is the email address, and a line
   set larger than the thing it sits beneath will always be read first whether
   or not it is the more important of the two.

   Roughly .proof__label's size, which is the size this page uses for "here is
   a bit of context", and that is exactly what this is. */
.closing__note {
  max-width: 720px;
  margin: clamp(28px, 3vw, 40px) auto 0;
  padding-top: clamp(18px, 2vw, 24px);
  border-top: 1px solid var(--hairline);
  font-size: 14px;
  line-height: 1.6;
  color: var(--fg-2);
}

/* The attribution goes on its own line and a step quieter again, so the
   sentence reads as the quote and this reads as the footnote to it. */
.closing__note-by {
  display: block;
  margin-top: 6px;
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--fg-3);
}
/* --------------------------------------------------------------------------
   17. Contact
   --------------------------------------------------------------------------
   The address, then the form behind a door. Anyone who already knows what they
   want to say is better served by their own mail client than by four inputs in
   a browser, so the address goes first and gets headline treatment. The form is
   for everyone else, and it is closed until asked for.
   -------------------------------------------------------------------------- */
/* The `ch` here is measured against THIS element's font size, which is body
   size - not against the 60px heading inside it. Sizing the wrapper to a
   heading measure is what broke it the first time: 20ch of 16px text is about
   160px, and the headline came out one word per line. The wrapper gets a
   paragraph measure; the heading sets its own. */
.cta { max-width: 56ch; }
.cta .h2 { margin-top: 22px; max-width: 16ch; }
.cta__lede { margin-top: 18px; font-size: 16.5px; max-width: 46ch; }

/* ---- The address ---- */
.mailto {
  margin-top: 52px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  border: 1px solid var(--hairline-strong);
  border-radius: var(--r);
  background-color: var(--surface);
  padding: 30px clamp(20px, 3vw, 40px);
  transition: border-color 0.22s var(--ease), background-color 0.22s var(--ease);
}

.mailto:hover {
  border-color: var(--brand);
  background-color: var(--surface-2);
}

.mailto__body { display: flex; flex-direction: column; gap: 16px; min-width: 0; }
.mailto__label { color: var(--fg-3); }

/* Set at heading scale but NOT in caps and NOT condensed: an address is a
   string somebody has to read character by character and possibly retype, and
   condensed caps is the worst thing you can do to one. It is large because it
   matters, not because it is a headline. */
.mailto__addr {
  font-size: clamp(21px, 4.2vw, 44px);
  font-weight: 600;
  letter-spacing: -0.02em;
  line-height: 1;
  color: var(--fg);
  /* Long, unbreakable, and sitting in a flex row - without this it pushes the
     arrow off the right edge on a narrow screen instead of shrinking. */
  overflow-wrap: anywhere;
}

.mailto__arrow {
  flex-shrink: 0;
  font-size: 20px;
  color: var(--fg-3);
  transition: transform 0.22s var(--ease), color 0.22s var(--ease);
}

.mailto:hover .mailto__arrow { transform: translate(4px, -4px); color: var(--brand); }

/* ---- The form, behind a door ---- */
.disclose { margin-top: 20px; }

/* A full-width bar rather than a button in a corner, because it is the only
   thing to press at this point in the page and it should look like it. */
.disclose__btn {
  position: relative;
  overflow: hidden;
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  border: 1px solid var(--hairline-strong);
  border-radius: var(--r);
  padding: 26px clamp(20px, 3vw, 40px);
  font-family: var(--mono);
  font-size: clamp(12px, 1.6vw, 15px);
  font-weight: 500;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--fg);
  text-align: left;
  transition: color 0.22s var(--ease), border-color 0.22s var(--ease);
}

/* Same wipe as the ghost button, so pressing this feels like the rest of the
   site rather than like a separate widget. */
.disclose__btn::before {
  content: "";
  position: absolute;
  inset: 0;
  background-color: var(--fg);
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform 0.34s var(--ease);
}

.disclose__btn:hover::before { transform: scaleX(1); }
.disclose__btn:hover { color: var(--base); border-color: var(--fg); }
.disclose__btn > * { position: relative; z-index: 1; }

.disclose__chev {
  flex-shrink: 0;
  font-size: 15px;
  letter-spacing: 0;
  transition: transform 0.28s var(--ease);
}

.disclose__btn[aria-expanded="true"] .disclose__chev { transform: rotate(180deg); }

/* Height is animated via a grid track rather than max-height, so the panel
   opens to exactly the height of its contents - no guessed maximum that clips
   a long validation message, and no jump when the contents change. */
.disclose__panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.42s var(--ease);
}

.disclose__btn[aria-expanded="true"] + .disclose__panel { grid-template-rows: 1fr; }

.disclose__panel > div { overflow: hidden; }

.form {
  display: flex;
  flex-direction: column;
  gap: 24px;
  border: 1px solid var(--hairline-strong);
  border-top: 0;
  padding: 36px clamp(20px, 3vw, 40px);
}

.form__grid { display: grid; gap: 22px; }
@media (min-width: 640px) { .form__grid { grid-template-columns: repeat(2, 1fr); } }

.form__field { display: flex; flex-direction: column; gap: 10px; }

.field {
  width: 100%;
  border: 1px solid var(--hairline-strong);
  border-radius: var(--r);
  background-color: var(--surface);
  padding: 13px 15px;
  font-family: inherit;
  font-size: 15.5px;
  color: var(--fg);
  transition: border-color 0.16s var(--ease), background-color 0.16s var(--ease);
}

.field::placeholder { color: var(--fg-3); }

.field:focus {
  outline: none;
  border-color: var(--brand);
  background-color: var(--surface-2);
}

textarea.field { resize: vertical; min-height: 124px; }

.form__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  border-top: 1px solid var(--hairline);
  padding-top: 26px;
}

.form__note { font-size: 13.5px; color: var(--fg-3); }

.form__status { font-size: 14px; }
.form__status--error { color: var(--bad); }
.form__status--ok { color: var(--ok); }

/* Honeypot - hidden from people, visible to naive bots. */
.hp {
  position: absolute;
  left: -9999px;
  width: 1px; height: 1px;
  opacity: 0;
}

.form-success { padding-block: 8px; }

.form-success__mark {
  width: 28px; height: 2px;
  background-color: var(--ok);
  margin-bottom: 20px;
}


/* --------------------------------------------------------------------------
   18. Blog
   -------------------------------------------------------------------------- */
.post-list { margin-top: 48px; display: flex; flex-direction: column; }

.post-row {
  display: grid;
  gap: 8px;
  border-top: 1px solid var(--hairline);
  padding-block: 26px;
  transition: border-color 0.2s var(--ease);
}

.post-row:last-child { border-bottom: 1px solid var(--hairline); }
.post-row:hover { border-top-color: var(--brand); }

@media (min-width: 860px) {
  .post-row {
    grid-template-columns: 160px 1fr 40px;
    align-items: baseline;
    gap: 28px;
  }
}

.post-row__title {
  font-size: clamp(21px, 2.4vw, 29px);
  font-weight: 700;
  font-variation-settings: "wdth" 84;
  text-transform: uppercase;
  letter-spacing: 0.01em;
  line-height: 1.05;
}
.post-row__dek { margin-top: 10px; font-size: 15px; color: var(--fg-2); max-width: 60ch; line-height: 1.55; }
.post-row__arrow { color: var(--fg-3); justify-self: end; transition: transform 0.2s var(--ease), color 0.2s var(--ease); }
.post-row:hover .post-row__arrow { transform: translateX(4px); color: var(--brand); }

.prose { max-width: 68ch; }
.prose > * + * { margin-top: 22px; }
.prose p { font-size: 16.5px; line-height: 1.72; color: var(--fg-2); }
.prose h2 {
  font-size: clamp(23px, 2.7vw, 32px);
  font-weight: 700;
  font-variation-settings: "wdth" 86;
  text-transform: uppercase;
  letter-spacing: 0.012em;
  line-height: 1.06;
  margin-top: 52px;
  color: var(--fg);
}

.prose h3 {
  font-size: 18px;
  font-weight: 600;
  font-variation-settings: "wdth" 94;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  margin-top: 34px;
  color: var(--fg);
}
.prose ul { display: flex; flex-direction: column; gap: 10px; }
.prose li { display: flex; gap: 14px; font-size: 16.5px; line-height: 1.65; color: var(--fg-2); }
.prose li::before { content: ""; margin-top: 13px; width: 8px; height: 1px; background-color: var(--fg-3); flex-shrink: 0; }
.prose strong { color: var(--fg); font-weight: 600; }
.prose a { color: var(--fg); text-decoration: underline; text-decoration-color: var(--brand); text-underline-offset: 3px; }
.prose code {
  font-family: var(--mono);
  font-size: 0.9em;
  background-color: var(--surface-2);
  border: 1px solid var(--hairline);
  border-radius: 2px;
  padding: 2px 6px;
}

.empty-state {
  margin-top: 48px;
  border: 1px solid var(--hairline);
  border-radius: var(--r);
  padding: 48px 28px;
  text-align: center;
  color: var(--fg-3);
}

/* --------------------------------------------------------------------------
   19. Footer
   -------------------------------------------------------------------------- */
.footer { border-top: 1px solid var(--hairline); }
.footer__inner { padding-block: 56px; }

.footer__grid { display: grid; gap: 40px; }

/* Four columns since the About section folded its last two facts in here.
   The brand column keeps the most room; the three lists share the rest. */
@media (min-width: 760px) {
  .footer__grid { grid-template-columns: repeat(2, 1fr); gap: 36px 40px; }
}

@media (min-width: 1060px) {
  .footer__grid { grid-template-columns: 5fr 3fr 4fr 3fr; gap: 40px; }
}

.footer__tagline { margin-top: 20px; max-width: 38ch; font-size: 14.5px; }
.footer__brand .brand { font-size: 16px; }
.footer__heading { margin-bottom: 20px; }
.footer__list { display: flex; flex-direction: column; gap: 12px; }
.footer__list a { font-size: 14.5px; color: var(--fg-2); transition: color 0.18s var(--ease); }
.footer__list a:hover { color: var(--fg); }

.footer__status {
  margin-top: 24px;
  display: inline-flex;
  align-items: center;
  gap: 10px;
}

.footer__bottom {
  margin-top: 56px;
  border-top: 1px solid var(--hairline);
  padding-top: 24px;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 14px 28px;
}

.footer__legal {
  display: flex;
  flex-wrap: wrap;
  gap: 20px;
  font-family: var(--mono);
  font-size: 11.5px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--fg-3);
}

.footer__legal a { transition: color 0.18s var(--ease); }
.footer__legal a:hover { color: var(--fg); }
.footer__legal a[aria-current] { color: var(--fg-2); }

/* --------------------------------------------------------------------------
   20. Storage notice - removed
   --------------------------------------------------------------------------
   The card is gone and so are its styles. See the note in main.js: no
   cookies, no analytics, nothing to consent to. privacy.html still says all
   of it, and the footer still links there.
   -------------------------------------------------------------------------- */

/* --------------------------------------------------------------------------
   21. Scroll reveal
   --------------------------------------------------------------------------
   A short fade as a block arrives. Kept because it marks the page handing you
   the next thing to read; kept small because that's all it does. */
.reveal {
  opacity: 0;
  translate: 0 8px;
  transition: opacity 0.5s var(--ease), translate 0.5s var(--ease);
}

.reveal.is-in { opacity: 1; translate: none; }

/* If JS never runs, nothing may stay hidden. */
.no-js .reveal { opacity: 1; translate: none; }

/* --------------------------------------------------------------------------
   22. Reduced motion
   -------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  .reveal { opacity: 1; translate: none; transition: none; }
  /* JS still writes --hero-p for its own reasons, so the backdrop is pinned
     to its assembled state here instead. !important is what lets a rule beat
     the inline custom property. */
  .hero { --hero-p: 0 !important; }
  .hero__floor { background-position: center 0; }
  .hero__cue { opacity: 0.7; }
  *, *::before, *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
  }
}
