@charset "UTF-8";
/* ============================================================
   HOME — a quiet page holding space for one loud object.

   The hero is a full-viewport black stage: copy in the top corners,
   the works kicker at the foot, and the middle deliberately empty —
   the centrepiece that will live there is still being decided, so
   the composition stands on typography and negative space alone.
   Below it, the selected-works index is deliberately just rows.
   ============================================================ */

/* ---------------------------------------------------------------
   1. HERO
   --------------------------------------------------------------- */

/* The hero clips — but not tightly. The fleet's ring pattern is centred
   wherever S was pressed and is deliberately NOT resized to fit the
   section (that produced a pattern landing somewhere the visitor had not
   pointed), so it has to be allowed to run off the edge. A clip margin is
   exactly that permission: still a clip, so nothing escapes indefinitely,
   but with room for the widest ring to hang past the boundary. */
.hero {
  position: relative;
  overflow: clip;
  /* ONE MEASURE for the controls in the corner: the cap's own size, how
     far the row of them sits off the bottom, and the air between the row
     and the log above it. The log is placed off all three, so the stack
     cannot drift apart the way two hand-kept offsets always eventually
     do. */
  --cap: clamp(38px, 3.4vw, 52px);
  --keys-bottom: clamp(20px, 3.4vh, 46px);
  --keys-gap: clamp(12px, 1.4vh, 20px);
  /* A PLAIN LENGTH, not a clamp: overflow-clip-margin takes a length and
     this engine will not accept a math function in it — clamp() and min()
     both make the whole declaration invalid, and it is dropped in silence,
     which looks exactly like the clip margin not working. Viewport units
     are fine, so the figure tracks the height, which is what bounds the
     ring size in the first place: the widest ring is a little under a
     third of the stage's height, and the furthest it can hang past an edge
     is that same radius. 34vh covers it with room over. */
  overflow-clip-margin: 34vh;
}

/* The fleet's stage: the whole hero, above the copy, untouchable.
   difference is what keeps a drone legible over ANYTHING it crosses —
   white over the ink stays white, white over the white statement
   inverts to ink, pixel for pixel. No geometry ever has to know where
   the text is. */
.hero-fleet {
  position: absolute; inset: 0; z-index: 2;
  pointer-events: none;
  /* An <svg> root clips to its own viewport by default, which is a second
     boundary in the way of the same thing — the hero's clip margin would
     have granted room that this then took back. */
  overflow: visible;
  /* white in both themes — see --difference-source. The blend is what
     gives each drone its colour: white over the lit statement reads
     black, white over the ground reads as the ground's opposite. */
  color: var(--difference-source);
  mix-blend-mode: difference;
}

/* ---- the hive ----------------------------------------------------
   Where the fleet lives. It sits in the hero's bottom-right corner —
   the one quiet part of the composition, diagonally opposite the
   statement — and is the only thing on this stage that answers the
   pointer, so it holds still and lets the arrival do the moving.

   Above the fleet in z, so a drone crossing the cell passes behind
   the door it is heading for. */
.hive {
  position: absolute; z-index: 3;
  /* Set in from the corner rather than tucked into it, and in per-cent
     so it holds the same place in the composition at every size. The
     corner itself is where a badge goes; the cell is a place on the
     stage the fleet flies to, and it needs air on the outside of it as
     well as the inside — the drones arrive from all four sides.

     Well clear of the window edge either way, which the reticle's crop
     needs: the frame reaches 22px past the cell, and a corner mark cut
     off by the window edge is a frame that failed to frame. */
  right: 18%; bottom: 24%;
  width: 44px; height: 44px;
  display: grid; place-items: center;
}

/* ON A PHONE IT SITS LOWER, and the percentage is why it has to be told
   to. A share of the height keeps the cell in the same place in the
   COMPOSITION, which is right on a wide stage — but the mobile hero is
   much taller relative to its width, so the same 24% becomes 178px of
   air underneath and the cell drifts up into the statement instead of
   holding the quiet corner it was put in. A smaller share puts it back
   down where the composition wants it.

   It cannot collide with the A/S/D caps: those are bottom-LEFT and end
   well short of this, and the cell keeps enough floor under it for the
   reticle's frame, which reaches 22px past the cell on every side. */
@media (max-width: 640px) {
  .hive { bottom: 11%; }
}

/* A cell wall, not an outline: a thick ring with a hole in it, so the
   shape reads as something with a way IN rather than a symbol of one. The site's own ink and paper, nothing else: at rest it
   sits back to grey, and being called brings it up to white — the same
   two tones the whole page is built from, so the corner never becomes a
   second colour story. */
.hive-cell {
  width: 100%; height: 100%;
  fill: var(--paper);
  /* How full it is IS how bright it is: empty rests at the grey it has
     always rested at, and a full hive is white on its own, untouched.
     --fill is set by home.js from the same count the berths read. */
  opacity: calc(0.42 + 0.58 * var(--fill, 0));
  transform-origin: 50% 50%;
  transition: opacity 320ms var(--ease), transform 420ms var(--ease-soft);
}

/* Called: the cell comes up to full and leans forward a touch — the
   drawing only. The box itself never moves: a hit area that grows on
   hover walks its own edge out from under a pointer sitting near it,
   and the fleet would be called home and released again in a loop. */
.hive:hover .hive-cell { opacity: 1; transform: scale(1.08); }

/* ---- the berths ---------------------------------------------------
   Sixteen marks outside the wall, four to a side — one per drone, and
   the fleet is sixteen. Off the cell rather than on it: the wall is the
   thing with a way in, and a mark drawn on it would read as a door.
   Unlit they are barely there, which is the point — an empty hive is a
   place with berths, not a gauge reading zero.

   Behind the cell in z and deaf to the pointer, so the hit area stays
   exactly the 44px box the fleet is aimed at. */
.hive-slots {
  position: absolute;
  /* 60 units across the cell's 44. max-width is reset because the base
     sheet caps every svg at its container, and this one is meant to
     overhang. */
  width: 136.36%; max-width: none;
  height: 136.36%;
  overflow: visible;
  pointer-events: none;
}
.hive-slot {
  fill: var(--paper);
  opacity: 0.16;
  transition: opacity 300ms var(--ease);
}
/* stationed: the berth is taken, and says so */
.hive-slot[data-on] { opacity: 0.9; }
/* the whole ring comes up with the cell when the fleet is called */
.hive:hover .hive-slot { opacity: 0.26; }
.hive:hover .hive-slot[data-on] { opacity: 1; }

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

/* ---- the count --------------------------------------------------
   How many are home, printed in the opening they went through. It is
   the one number on this stage that changes for a reason the visitor
   caused, so it is set in the site's mono like every other readout,
   and it holds its width: a count that shifts the digits sideways as
   it passes ten reads as two different objects. */
.hive-count {
  position: absolute;
  font-size: 11px;
  font-weight: 400;
  line-height: 1;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0;
  color: var(--t-primary);
  /* the number keeps step with the cell it is printed in */
  opacity: calc(0.5 + 0.5 * var(--fill, 0));
  transition: opacity 320ms var(--ease);
  pointer-events: none;
}
/* an empty hive has nothing to report */
.hive-count[data-zero] { opacity: 0; }
.hive:hover .hive-count { opacity: 1; }

/* ---- the name -----------------------------------------------------
   FULL, and only full: the cell fills, the last berth lights, and then
   the object says what it is. A label that was always there would be a
   caption; one that arrives on the last arrival is the fleet finishing
   its own sentence.

   It sits on the line the reticle's top edge runs along, so a pointer
   resting on a full cell reads it as that frame's own caption rather
   than as a second thing near it — 22px is cursor.js's crop padding,
   and the half-line back up centres the type ON that edge instead of
   above it.

   Set at the count's size, because the hive has two readouts and two
   sizes would make them two objects. Deaf to the pointer: the hit area
   is the 44px box the fleet is aimed at, and nothing may widen it. */
.hive-label {
  position: absolute;
  left: 50%;
  top: -22px;
  font-size: 11px;
  line-height: 1;
  letter-spacing: var(--mono-track);
  text-transform: uppercase;
  color: var(--t-primary);
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  /* the centring, plus the short way it comes up out of the cell */
  transform: translate(-50%, calc(-50% + 3px));
  transition: opacity 320ms var(--ease), transform 320ms var(--ease-soft);
}
.hive[data-full] .hive-label {
  opacity: 1;
  transform: translate(-50%, -50%);
}

@media (prefers-reduced-motion: reduce) {
  .hive-cell { transition: none; }
  .hive-label { transition: none; }
}

/* ---- the fleet's controls -----------------------------------------
   Three keycaps in the hero's bottom corner. They are drawn as KEYS
   because that is what they are — the letters work whether or not the
   caps are clicked, and a cap that looks like a key says so without a
   line of copy. Outline only, at the weight of the site's other marks:
   this is a legend that happens to be pressable, and a filled button
   would compete with the fleet it commands.

   Set in from the corner by the hero's own inset so they sit on the same
   left edge as the statement above them — the composition already has a
   left margin and a control panel is not a reason to invent a second.

   Hidden until home.js has wired them, and gone entirely under reduced
   motion: there is no fleet to command in a single still frame. */
.fleet-keys { display: none; }
.fleet-keys[data-ready] {
  position: absolute;
  z-index: 3;
  left: var(--hero-pad-x);
  bottom: var(--keys-bottom);
  display: flex;
  gap: clamp(8px, 0.9vw, 14px);
}

.fleet-key {
  width: var(--cap);
  aspect-ratio: 1;
  display: grid;
  place-items: center;
  border: 1px solid var(--t-dim);
  /* a key is a rounded square, not a pill: the radius is a fraction of
     the cap so it holds the same shape at every size the clamp gives */
  border-radius: 26%;
  color: var(--t-second);
  background: transparent;
  font-family: var(--mono);
  font-size: clamp(13px, 1.15vw, 16px);
  line-height: 1;
  letter-spacing: 0;
  transition: color var(--fast) var(--ease), border-color var(--fast) var(--ease),
              background var(--fast) var(--ease), transform var(--fast) var(--ease);
}
.fleet-key:hover {
  color: var(--t-primary);
  border-color: var(--t-primary);
}

/* THE PRESS. One state, and every route into the order lands on it: a
   click, Enter or Space on a focused cap, or the bare letter typed
   anywhere on the page — home.js sets data-hit from the fleet's own
   report, so the cap that lights is the cap whose order actually ran.
   It goes down and in, the way a key does; a cap that only changed
   colour would read as a hover. */
.fleet-key[data-hit] {
  color: var(--ink);
  background: var(--paper);
  border-color: var(--paper);
  transform: scale(0.92);
  /* the press itself is instant — a 180ms fade into a keystroke is a
     keystroke that already happened */
  transition: none;
}

/* AND THE LATCH. S is not a moment, it is a standing order: while the
   rings are up the cap stays lit, because the visitor needs to know
   there is something to switch off. Dimmer than the press, so the two
   never read as the same event. */
.fleet-key[aria-pressed="true"] {
  color: var(--t-primary);
  border-color: var(--t-primary);
  background: var(--chip);
}

/* ---- the command log -----------------------------------------------
   A TICKER, not a list, and the difference is the whole implementation.
   The box is a fixed number of lines tall and it clips; lines are
   appended at the bottom and the column is bottom-justified. So a new
   line growing from nothing to one line tall lifts everything above it
   by exactly one line, and the oldest leaves through the top — one
   animation on one new element does the work a scroll would need a
   scroll container and a scrollTop for.

   It is also why a line can be dropped from the DOM without anything
   moving: by the time it goes it is already above the box, clipped, and
   the lines that remain are pinned to the bottom edge either way.

   The mask makes that top edge an edge rather than a cut. The ageing
   opacity means a line is nearly gone before it reaches it, and the mask
   takes the rest — so nothing is ever seen being sliced in half. */
.fleet-log { display: none; }
.fleet-log[data-ready] {
  /* the type first, because the line box is measured off it: eight lines
     of it is the height of the whole log */
  --log-size: clamp(10px, 0.8vw, 12px);
  /* the leading as a NUMBER and the line box in em, so home.js can read
     both off the computed style and work out how many lines the room
     above the row will take. A calc() of a clamp() cannot be read back:
     getPropertyValue hands over the expression, not the pixels. */
  --log-lh: 2;
  --log-line: calc(1em * var(--log-lh));
  --log-lines: 8;

  position: absolute;
  z-index: 3;
  left: var(--hero-pad-x);
  /* clear of the row, off the row's own measures */
  bottom: calc(var(--keys-bottom) + var(--cap) + var(--keys-gap));

  display: flex;
  flex-direction: column;
  justify-content: flex-end;      /* the newest line is flush with the row */
  height: calc(var(--log-lines) * var(--log-line));
  overflow: hidden;
  /* the stage is underneath, and a click belongs to the stage */
  pointer-events: none;

  font-family: var(--mono);
  font-size: var(--log-size);
  letter-spacing: var(--mono-track);
  text-transform: uppercase;
  color: var(--t-primary);

  -webkit-mask-image: linear-gradient(to top, #000 62%, transparent);
  mask-image: linear-gradient(to top, #000 62%, transparent);
}

/* --log-lines is the CEILING, and home.js writes the real figure: the
   stack rises out of the corner toward the statement, and how much room
   there is between the two is a measurement, not a breakpoint. A media
   query would have guessed — the statement's foot moves with the width
   as its lines rewrap, not only with the height. */

.fleet-log-line {
  flex: none;
  height: var(--log-line);
  line-height: var(--log-line);
  overflow: hidden;
  /* age 0 is the order just given, and every line above it is one step
     further back and one step quieter. Set on every order rather than
     once at birth: a line's age is its distance from the newest one, and
     that changes underneath it. */
  opacity: max(0, 1 - var(--age, 0) * 0.13);
  transition: opacity var(--mid) var(--ease);
  /* the line arrives by making room for itself, and that is what lifts
     the column. No keyframe here touches opacity, so the ageing rule
     above is still the only thing in charge of it afterwards. */
  animation: fleet-log-rise var(--mid) var(--ease-soft);
}
@keyframes fleet-log-rise { from { height: 0; } }

/* and the word fades up into the room the line has just made for it */
.fleet-log-line > span {
  display: block;
  animation: fleet-log-word var(--mid) var(--ease-soft);
}
@keyframes fleet-log-word {
  from { opacity: 0; transform: translateY(0.55em); }
}

@media (prefers-reduced-motion: reduce) {
  .fleet-keys[data-ready] { display: none; }
  /* the log goes with the keys: it is a record of a simulation that is
     not running, kept for a panel that is not there */
  .fleet-log[data-ready] { display: none; }
}

/* Full width, like the nav: no --maxw cap and no centring, so the
   statement starts at a fixed inset from the window edge at every size
   rather than drifting inward as the viewport grows. */
.hero-inner {
  position: relative; z-index: 1;
  /* A window less 220px: the stage is deliberately shorter than the
     fold, so the section under it shows at rest and the page is visibly
     a page rather than a screen.

     SMALL viewport units, not dynamic ones. dvh is DEFINED to change as
     a mobile browser retracts its own chrome, which means the stage grew
     and shrank under the reader's thumb and every section below it slid
     up and down with the URL bar — the page never held still. svh is the
     window with that chrome SHOWN, and it does not move when the chrome
     does: the layout is settled before the first scroll and stays
     settled. The vh line above it is the fallback for anything that does
     not know svh yet — it will not track the chrome either. */
  min-height: calc(100vh - 220px);
  min-height: calc(100svh - 220px);
  /* One source of top space, not two: the statement carries no margin of
     its own, so this padding is the whole gap under the nav and there is
     a single number to move. Measured from the nav's actual content (the
     Contact button's bottom edge), not from --nav-h itself — --nav-h
     already includes --nav-pad-b below that edge, so subtracting it back
     out is what makes 60px the true gap rather than 60px-plus-padding.
     The type's own leading above the caps then adds a bit more before the
     letters themselves start, so the lettershapes read a little lower. */
  padding: calc(var(--nav-h) - var(--nav-pad-b) + 60px) var(--hero-pad-x) var(--s5);
  display: grid;
  /* the statement, then the space the fleet flies — the foot is gone */
  grid-template-rows: auto 1fr;
}

/* --- the statement ------------------------------------------------
   One block, two tones: the fact in white, the claim in grey. The
   measurements are the design's own — 54.74px, 116% line height,
   −5% tracking — with the size alone easing down on small screens. */

.hero-intro {
  font-family: var(--sans);
  /* Fluid across desktop, and the two numbers below are one decision: the
     measure is 16.3em, so 4.6vw of type makes the block 75vw. The
     statement therefore holds the same share of the window at every size,
     and the line breaks never move — measure and type scale together, so
     the wrap is locked to the ratio rather than to any particular width.
     The floor keeps it readable on phones (below ~674px the type stops
     shrinking and the lines simply rewrap); the ceiling stops it running
     away past ~2300px. 54.74px, the design's own size, lands at 1190px. */
  font-size: clamp(31px, 4.6vw, 106px);
  line-height: 1.16;
  letter-spacing: -0.05em;
  /* sized between the white sentence's longest line and the grey one's
     with "into" attached, so both wrap exactly as the design does */
  max-width: 16.3em;
}

/* ---- the places -------------------------------------------------
   A row of pills over the statement: where the work has been done,
   most recent first. Only the current one is filled — the rest are
   where it came from, and a row of four identical pills would say
   they are four choices rather than one place and its road.

   Sans, not mono: this is copy the eye reads with the sentence under
   it, not a machine readout. It rises with the statement, a beat
   ahead of it, so the block arrives as one thing. */
.hero-places {
  display: flex; flex-wrap: wrap; gap: 6px;
  margin: 0 0 22px;
  padding: 0;
  list-style: none;
  font-family: var(--sans);
  font-size: 14px;
  letter-spacing: -0.01em;
  animation: hero-rise 700ms var(--ease) 60ms both;
}
.place {
  padding: 7px 15px 8px;
  border-radius: 999px;
  background: var(--chip);
  color: var(--t-second);
  white-space: nowrap;
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}
.place[data-here] { background: var(--paper); color: var(--ink); }
/* the road answers the pointer; the place being lived in is already
   as loud as it gets */
.place:not([data-here]):hover { background: var(--chip-hi); color: var(--t-primary); }

@media (max-width: 560px) {
  .hero-places { font-size: 12.5px; gap: 5px; margin-bottom: 18px; }
  .place { padding: 6px 12px; }
}

.hero-line { color: var(--t-primary); font-weight: 400; }
/* Solid grey, not white at 62%: this line carries a marked-up word, and
   translucent letters let the marks under them show straight through the
   descenders. Same grey on this black ground either way. */
.hero-line--dim { color: var(--t-second-solid); }

.hero-line { animation: hero-rise 700ms var(--ease) 140ms both; }
.hero-line--dim { animation-delay: 300ms; }

/* One word marked the way a spellchecker marks one: red dots under it,
   and nothing else — the word itself keeps the sentence's grey, because
   a flag is something put ON a word rather than a change to it. The dots
   run straight through the descender rather than dodging it, which is
   what makes the line read as a machine's mark and not as typography. */
.flagged {
  text-decoration-line: underline;
  text-decoration-style: dotted;
  /* grey until it is looked at: the flag is there the whole time, and
     the pointer is what makes it insist */
  text-decoration-color: var(--t-dim);
  text-decoration-thickness: 0.055em;
  text-underline-offset: 0.12em;
  text-decoration-skip-ink: none;
  transition: text-decoration-color var(--fast) var(--ease);
}
.flagged:hover { text-decoration-color: var(--flag); }

/* the inline glyphs ride the text: currentColor, sized to the caps,
   sitting a touch under the baseline the way an o would */
.ico {
  display: inline-block;
  height: 0.78em;
  width: auto;
  vertical-align: -0.05em;
}
/* The chair is the one glyph that is taller than it is wide, and a
   portrait mark set to a square mark's height reads as a smaller
   object. It takes the sentence's cap height instead — standing on the
   baseline like a letter, because that is what it is doing here. */
.ico--chair {
  /* Bigger than the square marks, because a chair drawn in outline
     carries less ink than a filled octagon and reads smaller at the
     same height. It also sits lower: the seat, not the backrest, is
     what the eye lines up with the letters.

     The drop is a relative offset rather than more vertical-align on
     purpose — vertical-align grows the line box, and a glyph that
     pushed its own line apart would open a gap between the statement's
     two lines that none of the other marks open. */
  height: 0.96em;
  vertical-align: -0.03em;
  position: relative;
  top: 0.07em;
}

/* The mark carries its own values — the petals are drawn at 0.4 inside
   the artwork, the asterisk at full. So it must NOT also inherit the
   line's 62% grey: that multiplies every part a second time and the
   whole glyph goes see-through (petals to 25%, asterisk to 62%).
   Full white here means the artwork renders exactly as drawn, with a
   solid front. It is the only thing in the grey line that is not grey,
   which is right — it is a mark introducing the phrase, not text. */
.ico--self {
  height: 0.9em;
  vertical-align: -0.13em;
  color: var(--paper);
}
/* the mark introduces the phrase it belongs to, so it must never be
   left stranded at the end of a line without it */
.ico-word { white-space: nowrap; }

/* the phrase the mark introduces gets a sheen instead of a flat colour —
   the one spot in the grey sentence that brightens toward white, left to
   right, so it reads as the sentence's point rather than its footnote.
   Fallback colour first, for the (rare) browser without background-clip:
   text; the gradient then overrides it wherever it is supported. */
.hero-shine {
  color: var(--t-primary);
  background: linear-gradient(90deg, var(--t-second) 0%, var(--paper) 75%);
  -webkit-background-clip: text;
  background-clip: text;
  -webkit-text-fill-color: transparent;
}

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

/* ---------------------------------------------------------------
   2. SELECTED PROJECTS — four plates, two up, and a strip of words
   for a floor.

   The plates run to the hero's own inset rather than the centred
   --maxw measure: this section and the hero are one full-bleed
   stage, and an image the size of a case study has no business
   stopping 60px short of the window. Every figure below is off the
   reference — 1.69:1 plates, a 40px gutter between them, 24px of
   radius, and the caption sitting 24px under the picture.
   --------------------------------------------------------------- */

.works {
  /* the gutter between two plates, and the figure the plate measure is
     worked out from — one number, used by both */
  --work-gap: clamp(18px, 1.9vw, 40px);
  /* the plate's proportion, read by both the plate and the measure that
     sizes it — the two can never disagree about the shape */
  --work-ratio: 1.69;
  --work-radius: 67px;
  /* how far a plate rises into place on arrival. The row reads it too:
     the shelf holds exactly this much room at its foot so the rise
     happens inside the scroll box (see .works-grid) */
  --work-rise: 18px;
  /* and how far one lifts under the pointer. Same arrangement, other end:
     the shelf holds this much room at its head, because the lift is a
     transform and a transform has to have somewhere to go. */
  --work-lift: 4px;
  /* the strip is this section's floor, and it wants the About block's
     own top space under it rather than a second helping of its own */
  padding: var(--s6) var(--hero-pad-x) var(--s5);
}

/* the label. Same arrow that ends every link on the site, run at
   reading size in front of the words instead of after them — this is
   a heading pointing at what follows, not a link going anywhere. */
.works-head {
  display: flex;
  align-items: center;
  gap: 0.46em;
  /* the pager sits under this line now, so the heading's own space goes
     to it rather than to the plates — the label and the control it
     labels belong together, and the pager carries the distance down to
     the row (see .works-pager) */
  margin: 0 0 clamp(14px, 1.2vw, 22px);
  font-family: var(--sans);
  font-size: clamp(22px, 2.15vw, 34px);
  font-weight: 400;
  line-height: 1.1;
  letter-spacing: -0.02em;
  color: var(--t-primary);
}
.works-arrow {
  width: 1.06em; height: auto;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.35;
  stroke-linecap: square;
  stroke-linejoin: miter;
  vector-effect: non-scaling-stroke;
}

/* One row, scrolled sideways. Two plates are on stage at the measure
   the reference sets, and the rest are off to the right — a shelf you
   move along rather than a grid you read down, which is what a row of
   pictures the size of these ones wants to be.

   The scroller runs full-bleed and carries the section's inset as its
   own padding, so the first plate lands on the page's left edge and the
   last one can be scrolled clear of the right. */
.works-grid {
  display: flex;
  gap: var(--work-gap);
  margin-inline: calc(var(--hero-pad-x) * -1);
  padding-inline: var(--hero-pad-x);
  overflow-x: auto;
  /* One axis, and only one. `overflow-x: auto` on its own computes
     overflow-y to `auto` as well, which quietly makes the shelf a
     VERTICAL scroller too — and anything that overflows its foot by a
     pixel (the plates' arrival rise, a caption that wraps, a focus ring
     scrolled into view) is then enough for one diagonal trackpad flick
     to leave the row scrolled down with the tops of the plates cut off,
     with no scrollbar to say so and no obvious way back. So the axis is
     shut, and the rise is given room INSIDE the box rather than allowed
     to overflow it: padding to hold it, and a negative margin to hand
     that space back to the page so nothing below moves. */
  overflow-y: hidden;
  /* Room at both ends, and for the same reason: shutting the second axis
     means anything that leaves the box vertically is cut, and two things
     do — a plate rises 18px into place when it arrives, and lifts 4px
     under the pointer. The first overflows the foot, the second the head,
     and the head is the one that shows: a 4px slice off a 67px radius
     flattens the top of the corner while the eye is on it.

     So each end holds the room its own movement needs, and a negative
     margin hands that space straight back to the page — the shelf clips
     nothing and nothing around it moves. The head gets one pixel over the
     lift rather than exactly the lift: landing a 67px corner flush on the
     clip edge is right to the pixel and wrong to the half-pixel, and its
     antialiased top row is the part that would show. */
  padding-block: calc(var(--work-lift) + 1px) var(--work-rise);
  margin-top: calc((var(--work-lift) + 1px) * -1);
  margin-bottom: calc(var(--work-rise) * -1);
  overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;
  scroll-padding-inline-start: var(--hero-pad-x);
  /* no scrollbar: the plates running off the edge are the affordance,
     and a bar under them would be a second one */
  scrollbar-width: none;
}
.works-grid::-webkit-scrollbar { display: none; }

/* One plate to a window, near enough: 72vw is the design's own measure,
   and at 1.69:1 that is a picture over 600px tall — big enough that the
   work is the subject rather than a thumbnail of it. The next plate
   shows at the right edge, which is the row's first affordance — the
   pager above it is the other.

   The measure is capped by the height it would cost, not just by the
   width it asks for: a plate is 1/1.69 as tall as it is wide, so on a
   short window — or a very wide one — 72vw puts the foot of the picture
   and its caption off the bottom of the screen. Whichever of the two
   limits is smaller wins, so the whole plate is always on stage. */
.work {
  flex: 0 0 min(72vw, calc(78vh * var(--work-ratio)));
  scroll-snap-align: start;
}

.work-card { display: block; }

/* The picture, and its frame. A placeholder is still a plate: it holds
   the exact proportion and the exact radius the artwork will, so
   dropping an image in later moves nothing and crops nothing.

   One flat fill, no gradient — the site's own plate colour, which is a
   shade off the ground in both themes. A gradient across a frame that is
   about to hold a photograph is a light source the photograph does not
   have, and it shows at every edge where the two meet. */
.work-plate {
  display: block;
  width: 100%;
  aspect-ratio: var(--work-ratio);
  border-radius: var(--work-radius);
  background: var(--plate);
  /* the frame crops what it holds, so the artwork takes the radius
     rather than needing one of its own */
  overflow: hidden;
  transition: transform var(--mid) var(--ease-soft),
              filter var(--mid) var(--ease-soft);
  will-change: transform;
}

/* Artwork fills the frame it was measured for: cover rather than
   contain, so a photograph a few pixels off the ratio is trimmed
   instead of letterboxed, and centred so the trim is taken evenly off
   both sides. Sized here rather than on the element, so an <img>, a
   <video>, or a poster frame all drop in without markup of their own. */
.work-plate > img,
.work-plate > video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
}
/* nothing to outline, so hover lifts it and lets a little more light in */
.work-card:hover .work-plate {
  transform: translateY(calc(var(--work-lift) * -1));
  filter: brightness(1.35);
}

/* the caption: name, then what the work was. One line at every width
   the grid keeps two columns — the tags wrap before the name does. */
.work-foot {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--s2) 16px;
  padding-top: 24px;
}
.work-name {
  font-family: var(--sans);
  font-size: 18px;
  line-height: 1.2;
  letter-spacing: -0.01em;
  color: var(--t-primary);
  transition: color var(--fast) var(--ease);
}
.work-tags { display: flex; flex-wrap: wrap; gap: var(--s2); }
.work-tag {
  font-family: var(--sans);
  font-size: 13px;
  line-height: 1;
  padding: 6px 12px;
  border-radius: 999px;
  background: var(--chip);
  color: var(--t-second);
  white-space: nowrap;
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}

/* hover by subtraction, as everywhere: the card under the pointer keeps
   its resting colours and the rest step back. */
.works-grid:has(.work-card:hover) .work-card:not(:hover) .work-name { color: var(--recede-label); }
.works-grid:has(.work-card:hover) .work-card:not(:hover) .work-tag {
  background: var(--recede-chip);
  color: var(--recede-text);
}

/* plates arrive as you reach them. The hidden state only exists once
   home.js marks the section [data-io] — no script, no hiding. */
.works[data-io] .work {
  opacity: 0;
  transform: translateY(var(--work-rise));
  transition:
    opacity calc(var(--reveal) * 0.9) var(--ease-soft),
    transform var(--reveal) var(--ease-soft);
  transition-delay: calc(var(--wi, 0) * 80ms);
}
.works[data-io] .work[data-seen="true"] { opacity: 1; transform: none; }

/* ---- the pager: numbers on the left, steps on the right ----------
   The row is a shelf you move along, and the only sideways axis a
   wheel mouse has is shift — so the row does not take the vertical
   scroll (see home.js) and this takes the mouse instead. It reads as
   an index rather than a widget: the plate you are on is the only
   number at full strength, and the rule runs out of it towards the ones
   you have not reached. It sits under the heading and over the plates,
   where a label's controls belong — not at the foot of a shelf whose
   last plate is a screen away from it.

   Hidden until home.js writes the marks and wires the steps, so a
   script that never ran leaves no dead control on the page. */
.works-pager { display: none; }
/* and it arrives on the same terms as the plates: hidden only once
   home.js has marked the section [data-io], written once by the same
   observer, and never cleared — so scrolling back to the top never
   re-runs the entrance. Its --wi comes from the same batch as the
   plates', which is what makes the row and the shelf read as one
   sequence instead of two. */
.works[data-io] .works-pager[data-ready] {
  opacity: 0;
  transform: translateY(var(--work-rise));
  transition:
    opacity calc(var(--reveal) * 0.9) var(--ease-soft),
    transform var(--reveal) var(--ease-soft);
  transition-delay: calc(var(--wi, 0) * 80ms);
}
.works[data-io] .works-pager[data-ready][data-seen="true"] {
  opacity: 1;
  transform: none;
}
.works-pager[data-ready] {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s4);
  /* the whole distance from the heading to the plates, less the space
     the heading already gives this row */
  margin: 0 0 clamp(22px, 2.1vw, 36px);
}

.works-index {
  display: flex;
  align-items: center;
  /* the gap between two numbers, and the same figure the rule takes as
     its own breathing room — one measure, so the row stays even as the
     rule moves through it */
  --num-gap: clamp(11px, 1.15vw, 18px);
  gap: var(--num-gap);
}

/* A number, and — while it is the one you are on — the rule leaving it.
   The rule is the number's own ::after rather than a separate element,
   which is what lets it push the numbers after it along as it grows:
   the marks shuffle sideways when the row moves, so the control reads
   as one travelling thing rather than four lights taking turns. */
.works-num {
  display: inline-flex;
  align-items: center;
  padding-block: 7px;
  color: var(--t-faint);
  transition: color var(--fast) var(--ease);
}
.works-num:hover { color: var(--t-second); }
.works-num[aria-current="true"] { color: var(--t-primary); }

.works-num::after {
  content: "";
  height: 1px;
  width: 0;
  margin-left: 0;
  background: currentColor;
  transition: width var(--mid) var(--ease-soft),
              margin-left var(--mid) var(--ease-soft);
}
.works-num[aria-current="true"]::after {
  width: clamp(52px, 6.4vw, 118px);
  margin-left: var(--num-gap);
}

/* The steps. Same arrow the rest of the site ends its links with, at
   the same weight, and it leaves in the direction it points when the
   pointer lands on it. At either end of the row the step it cannot
   take goes quiet rather than disappearing — the shelf's length is
   worth knowing. */
.works-steps { display: flex; align-items: center; gap: var(--s1); }
.works-step {
  display: grid;
  place-items: center;
  width: 40px; height: 40px;
  border-radius: 999px;
  color: var(--t-second);
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}
.works-step:hover:not(:disabled) { background: var(--btn-bg); color: var(--t-primary); }
.works-step:active:not(:disabled) { background: var(--btn-bg-active); }
.works-step:disabled { color: var(--t-faint); cursor: default; }

.works-step-arrow {
  width: 21px; height: auto;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.35;
  stroke-linecap: square;
  stroke-linejoin: miter;
  vector-effect: non-scaling-stroke;
  transition: transform var(--fast) var(--ease);
}
/* one glyph, mirrored — the left step is the right one backwards, and
   the nudge below is written in the mirrored space with it */
.works-step[data-step="-1"] .works-step-arrow { transform: scaleX(-1); }
.works-step:hover:not(:disabled) .works-step-arrow { transform: translateX(2px); }
.works-step[data-step="-1"]:hover:not(:disabled) .works-step-arrow { transform: scaleX(-1) translateX(2px); }

/* ---- the strip: one line of words, endless -----------------------
   Full-bleed past the section's own inset, because a line with no end
   cannot be seen to stop. The track is moved by home.js (transform
   only), and the tint over it darkens the words as they travel right,
   so the same word is white on its way in and grey on its way out. */
.strip {
  position: relative;
  margin: clamp(48px, 4.8vw, 100px) calc(var(--hero-pad-x) * -1) 0;
  overflow: hidden;
  /* the very edges give out, so nothing is seen to be cut */
  mask-image: linear-gradient(90deg, transparent 0, #000 2.5%, #000 97.5%, transparent 100%);
}
.strip-track {
  display: flex;
  width: max-content;
  will-change: transform;
}
.strip-set {
  font-family: var(--sans);
  font-size: clamp(46px, 6.4vw, 136px);
  line-height: 1.16;
  letter-spacing: -0.02em;
  text-transform: uppercase;
  white-space: pre;
  color: var(--t-primary);
}
.strip-tint {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(90deg,
    rgba(var(--ink-tone), 0) 0%,
    rgba(var(--ink-tone), 0) 34%,
    rgba(var(--ink-tone), 0.42) 72%,
    rgba(var(--ink-tone), 0.58) 100%);
}

/* ---------------------------------------------------------------
   3. RESPONSIVE
   --------------------------------------------------------------- */

/* Two plates up is the design and every figure in it is a ratio, so a
   tablet needs nothing said about it — the clamps above close the
   gutter, the radius, and the spacing together. */
@media (max-width: 900px) {
  .works { padding-block: var(--s5) var(--s6); }
  .work-foot { padding-top: 16px; }
}

/* One up on a phone — a 1.69:1 plate at half a 640px window is 180px
   tall, which is not a picture of anything. It stops a little short of
   the full width so the next plate shows at the edge: on a phone there
   is no cursor to reveal that the row moves. */
@media (max-width: 640px) {
  /* The radius is a proportion of the plate, not a constant: 67px on a
     picture 166px tall is a lozenge, and it would eat the corners of
     every image the row ever holds. Half of it holds the same shape at
     a phone's measure. */
  .works { --work-radius: 34px; }
  /* a plate is already most of a phone's width; 86% leaves the sliver
     that says the row moves */
  .work { flex-basis: 86%; }
  .work-name { font-size: 16px; }
  .work-tag { font-size: 12px; padding: 6px 10px; }
}

@media (max-width: 720px) {
  .hero-inner {
    /* Less air over the statement than the desktop's 60px. That figure is
       a proportion of a wide stage, where the nav is one thin band across
       the top and the gap under it is the composition breathing. On a
       phone the nav is TWO stacked rows and already owns the top eighth of
       the screen, so the same 60px reads as a hole between two things that
       belong together — and it is 60px of a viewport that has to hold the
       whole statement before the fold. 26px keeps them apart without
       spending the screen on it. */
    padding-top: calc(var(--nav-h) - var(--nav-pad-b) + 26px);
    /* On a phone the stage takes almost the whole window, and what is
       left below the fold is exactly one line: the works heading. Its own
       top space, its line box, and a few pixels of air — no more, so the
       pager under it stays hidden and scrolling is what reveals the
       control rather than the fold half-showing it.

       Written as a sum of the parts it is made of rather than as one
       figure, because that is what it is: change the heading's size or
       the section's top space and this follows. */
    --fold-peek: calc(var(--s5) + 24px + 6px);   /* 40 + 22×1.1 + air */
    min-height: calc(100vh - var(--fold-peek));
    min-height: calc(100svh - var(--fold-peek));
  }
}

/* ---------------------------------------------------------------
   3b. ABOUT — four sizes, and the hierarchy is the whole layout

   The measurements are the reference's own, and they are the design:
   46/58 for the statement, 17/28 for the paragraph under it, 13/21 for
   the label and the disciplines. Three steps, each far enough from the
   last to be read as a different KIND of sentence rather than the same
   sentence at a different size — which is what lets the section carry
   three registers with no rules, no cards, and no ornament at all.

   The rail label sits out on the left the way the section numbers do
   on the works rows above: the page's left edge is already a column of
   small type, and About joins it rather than inventing a place.
   --------------------------------------------------------------- */

.about {
  max-width: var(--maxw);
  margin-inline: auto;
  padding: var(--s7) var(--pad-x) var(--s7);
  display: grid;
  grid-template-columns: 180px minmax(0, 1fr);
  column-gap: var(--s4);
  align-items: start;
  /* A drag here is how a rock is picked up and thrown (see drift.js),
     and the browser was answering the same gesture with a selection —
     the whole statement lighting up white behind a rock mid-flight.
     One gesture cannot mean two things: selection stands down, the same
     bargain every card on the wall already makes. The words are still
     the words — reading is untouched, and copying a line lives on the
     pages that are for reading, not on the section that is a toybox. */
  user-select: none;
  -webkit-user-select: none;
  /* The sand's containing block — see drift.js. It is the SECTION and not
     a wrapper of its own, so nothing has to be kept in step with it.

     THE STACKING ORDER IS LOAD-BEARING HERE, and not for the reason it
     usually is. The sand's box reaches 96px below this section, down
     into the band of air the footer keeps above its white slab, because
     that is where the black ground actually ends. But the footer carries
     isolation: isolate — so it is a stacking context, and a stacking
     context is painted as one atomic unit at the z-index:0 layer even
     when it is static with z-index auto. Later in the document than
     this section, therefore painted after it, and its background is
     opaque black: it covered the bleed exactly, and the pile came out
     with a hard flat line across its bottom.

     One z-index puts this section above it. Safe because the only thing
     of the footer's in that band is padding — there is nothing of its
     own down there to be covered — and the canvas stops flush with the
     top of the slab, so it never reaches anything white. */
  position: relative;
  isolation: isolate;
  z-index: 1;
}

/* THE SAND. Behind everything in the section, taking no pointer events
   of its own: it is scenery, and the pointer belongs to the text and the
   links over it.

   Placed but NOT SIZED here — drift.js sets the width and height in px,
   because a canvas is a replaced element and an `inset` shorthand cannot
   stretch one (it takes its intrinsic size instead). The box is also
   taller than this section on purpose: the sand's floor belongs where
   the black ground ends, which is inside the footer's band of air above
   its slab, not at this element's edge. Hence max-width: none — the base
   sheet caps every canvas at its container, and this one overhangs. */
.drift {
  position: absolute;
  left: 0;
  top: 0;
  max-width: none;
  z-index: -1;
  pointer-events: none;
}

.about > *:not(.drift) { position: relative; }

.about-rail {
  color: var(--t-dim);
  text-transform: uppercase;
  /* the smallest step, and the same one the disciplines use — a label
     and a list of disciplines are the same order of information */
  font-size: 13px;
  line-height: 21px;
  letter-spacing: 0.14em;
  margin-top: 10px;
}

/* Measures are set on the type they belong to, in its OWN em: a ch on
   the wrapper is a ch of the wrapper's 16px, which squeezed a 46px
   statement into a column nine words tall. */
.about-body { max-width: 62rem; }

/* the statement: the one loud thing in the section */
.about-lede {
  margin: 0;
  font-family: var(--sans);
  font-size: 46px;
  line-height: 58px;
  letter-spacing: -0.02em;
  font-weight: 500;
  color: var(--t-primary);
  /* ~4 lines at this size, which is the shape the reference makes */
  max-width: 17em;
}

/* and the paragraph, at the size a paragraph is actually read at */
.about-note {
  margin: var(--s5) 0 0;
  font-family: var(--sans);
  font-size: 17px;
  line-height: 28px;
  color: var(--t-second);
  max-width: 30em;
}

/* ---- the section arriving ----------------------------------------
   The statement comes up a line at a time out of its own edge; the rail,
   the paragraph and the figures follow it on a plain rise. One entrance,
   read top to bottom, over in well under a second — this is a section
   answering the scroll, not a section performing.

   The hidden state exists only once home.js has marked the section
   [data-split] (the heading has been measured into lines) or [data-io]
   is moot — no script, nothing hidden, the About block is simply there.
   That is the same bargain the works plates make, and it is the only
   version of this that is safe to write: an entrance that can leave the
   page blank when a module fails is not an entrance, it is a bug with a
   curve on it. */

/* The clipping boxes themselves are shared with the footer's philosophy
   lines and live in app.css — see "text that comes up a line at a time".
   What stays here is only the part that belongs to this section: the
   statement's lines and the three things that follow them in. */

/* the rest of the section: a rise, staggered behind the statement it
   belongs to. --ai is written here rather than in script — the order of
   these three never changes, and a number a stylesheet already knows is
   not the script's to send. */
.about[data-split] .about-rail { --ai: 0; }
.about[data-split] .about-note { --ai: 3; }
.about[data-split] .about-stats { --ai: 4; }
.about[data-split] .about-rail,
.about[data-split] .about-note,
.about[data-split] .about-stats {
  opacity: 0;
  transform: translateY(14px);
  transition:
    opacity 520ms var(--ease-soft),
    transform 620ms var(--ease-soft);
  transition-delay: calc(var(--ai, 0) * 70ms);
}
.about[data-split][data-seen="true"] .about-rail,
.about[data-split][data-seen="true"] .about-note,
.about[data-split][data-seen="true"] .about-stats {
  opacity: 1;
  transform: none;
}

/* a hand that asked for no motion gets the section, not the entrance */
@media (prefers-reduced-motion: reduce) {
  .about[data-split] .about-rail,
  .about[data-split] .about-note,
  .about[data-split] .about-stats {
    opacity: 1; transform: none; transition: none;
  }
}

/* ---- the figures ------------------------------------------------
   Two of them, where the list of disciplines used to be. A list of
   disciplines is a thing you have to READ, and it says what everyone
   else's list says; a number is the one thing on a page like this that
   can be taken in at a glance, which is how a page like this is
   actually looked at.

   The figure is a fourth type size on this section, and deliberately so
   — 46/58 for the statement, 17/28 for the paragraph, 13/21 for the
   rail, and this one bigger than any of them. It is the only place in
   the About block where the eye is meant to land before it reads.

   Two columns that stay two columns. Stacking them on a phone would
   make each figure a headline of its own and lose the comparison, which
   is the only reason there are two — the pair is the unit. */
.about-stats {
  display: grid;
  /* auto, not 1fr: the columns are as wide as the captions need and no
     wider. Equal fractions of a fixed measure orphaned the last word of
     each caption onto its own line — "cities worked / in" — which is the
     one thing a two-word annotation must not do. */
  grid-template-columns: repeat(2, auto);
  justify-content: start;
  gap: clamp(24px, 5vw, 76px);
  margin: var(--s6) 0 0;
}
.stat {
  display: flex;
  flex-direction: column;
  gap: clamp(6px, 0.7vw, 12px);
}
.stat-figure {
  font-family: var(--sans);
  font-size: clamp(34px, 4.4vw, 62px);
  font-weight: 500;
  line-height: 1;
  letter-spacing: -0.03em;
  color: var(--t-primary);
  /* the two figures line up whatever digits they hold — a 4 beside a
     17,000 must not sit on a different left edge tomorrow */
  font-variant-numeric: tabular-nums;
}

/* THE HOLLOW FIGURE. The sand under this row is two banks of rocks, one
   solid and one outlined, and the figure is set to match the bank it
   stands over — so the number and its material are the same object in
   the same state, and the pairing needs no key to explain it.

   GUARDED, because the fill has to go transparent for the stroke to be
   the only thing left: in an engine without text-stroke that declaration
   alone would leave an invisible number where a figure should be. Inside
   @supports the pair travels together or not at all, and the fallback is
   the solid figure everyone else gets.

   The stroke is a hair heavier than the rocks' own 1.5px, because it is
   carrying 62px of type rather than a 5px stone and the eye reads a
   long thin line as lighter than a short one. */
@supports (-webkit-text-stroke: 1px currentColor) {
  .stat[data-rock="hollow"] .stat-figure {
    color: transparent;
    -webkit-text-stroke: 1.8px var(--t-primary);
  }
}
.stat-label {
  /* FULL INK, not the dim register the other small labels use. That
     register assumes a plain ground behind it; these two sit at the top
     of the sand, and grey small caps over a field of white marks is the
     one combination on this page that cannot hold its own edge. The size
     and the letterspacing already say "annotation" — the colour does not
     have to say it a second time. */
  color: var(--t-primary);
  /* on one line, which is what keeps it an annotation rather than a
     sentence */
  white-space: nowrap;
}

@media (max-width: 900px) {
  .about {
    grid-template-columns: minmax(0, 1fr);
    row-gap: var(--s4);
    /* The air moves from the top to the bottom. Above: --s5, because the
       marquee already holds this section off the works and a phone has
       no screen to spend on a second, taller hello. Below: the figures
       are the last thing standing on ground the rocks pile up on, and a
       NARROW viewport stacks the same population higher — 64px under
       the captions was the overlap in the bug. The extra is written as
       s7 + 32 rather than one number so it keeps meaning "the desktop's
       clearance, plus what the narrowness costs". */
    padding-block: var(--s5) calc(var(--s7) + 32px);
  }
  /* the steps close up on a phone, but they stay three steps */
  .about-lede { font-size: clamp(28px, 6.4vw, 46px); line-height: 1.26; }
  .about-note { font-size: 16px; line-height: 26px; }
  /* the figures keep their pairing at every width; only the gutter closes */
  /* the pair stays a pair; only the gutter closes. nowrap captions are
     safe here because the phone measure is still wider than the longest
     of them */
  .about-stats { gap: clamp(18px, 6vw, 40px); margin-top: var(--s5); }
}

/* ---------------------------------------------------------------
   4. REDUCED MOTION — the composition, minus the choreography.
   The canvas handles itself (one still frame); this quiets the DOM.
   --------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  .hero-line { animation: none; opacity: 1; transform: none; }

  .works[data-io] .work { opacity: 1; transform: none; transition: none; }
}
