/* ==========================================================================
   Breathing grid — gallery dark
   Colours, spacing and type are driven by CSS variables that site.js sets
   from content.js, so the Studio can change them without touching this file.
   ========================================================================== */

:root {
  --canvas: #0e0e0e;
  --surface: #161616;
  --line: #262626;
  --ink: #f2f0ed;
  --ink-dim: #a3a09b;
  --ink-faint: #7a7772;
  --accent: #e8c547;

  --gutter: 24px;
  --logo-opacity: 0.72;

  --font: "Inter Tight", ui-sans-serif, system-ui, -apple-system, sans-serif;
  /* Labels are set in the site's own face, not a monospace.

     The monospace was whatever the machine happened to have — SF Mono on a
     Mac, Consolas on Windows — which left the small capitals as the one part
     of the site that could not be guaranteed to match across platforms.
     Using the face already being served removes that, and lets the page
     speak in one voice rather than setting mechanical labels against a
     humanist headline.

     A monospace carries width and weight for free at 11px. Inter Tight does
     not, so every label opens its tracking a little further and lifts to 500
     to hold the line at that size. */
  --label: var(--font);

  --pad: clamp(20px, 5vw, 64px);
  --display: clamp(2.5rem, 7vw, 6rem);
  --h1: clamp(2rem, 4.5vw, 3.5rem);
  --h2: clamp(1.35rem, 2.4vw, 2rem);
  --lead: clamp(1.05rem, 1.5vw, 1.3rem);

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

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

html {
  background: var(--canvas);
  color-scheme: dark;
  scroll-behavior: smooth;
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  background: var(--canvas);
  color: var(--ink);
  font-family: var(--font);
  font-size: 17px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
}

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

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

h1,
h2,
h3,
p {
  margin: 0;
}

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

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

.wrap {
  width: 100%;
  max-width: 1600px;
  margin: 0 auto;
  padding-inline: var(--pad);
}

.eyebrow {
  font-family: var(--label);
  font-weight: 500;
  font-size: 11px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.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;
}

/* ------------------------------------------------------------------ header */

.site-header {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 60;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 20px;
  padding: 20px var(--pad);
  transition: background 0.3s var(--ease), border-color 0.3s var(--ease);
  border-bottom: 1px solid transparent;
}

.site-header.is-stuck {
  background: rgba(14, 14, 14, 0.82);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
  border-bottom-color: var(--line);
}

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


.brand span {
  color: var(--ink-faint);
  font-weight: 400;
}

.site-nav {
  display: flex;
  gap: clamp(16px, 3vw, 34px);
  font-size: 14px;
  color: var(--ink-dim);
}

.site-nav a {
  position: relative;
  transition: color 0.2s var(--ease);
}

.site-nav a::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -4px;
  height: 1px;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.35s var(--ease);
}

.site-nav a:hover,
.site-nav a[aria-current="page"] {
  color: var(--ink);
}

.site-nav a:hover::after,
.site-nav a[aria-current="page"]::after {
  transform: scaleX(1);
}

/* -------------------------------------------------------------------- hero */

.hero {
  padding-top: clamp(140px, 22vh, 240px);
  padding-bottom: clamp(48px, 8vh, 96px);
}

.hero h1 {
  margin-top: 22px;
  max-width: 16ch;
  font-size: var(--display);
  font-weight: 500;
  line-height: 0.98;
  letter-spacing: -0.035em;
  text-wrap: balance;
}

/* The portrait sits beside the text, not at the far edge of the page.

   .hero-text was flex: 1, which made it eat all the spare width and shove the
   portrait against the right margin — the two ended up at opposite ends of a
   wide screen reading as two separate things. Letting the text size to its own
   content instead keeps the pair together as one composition. */
/* The gap here is only a floor — the real spacing comes from .hero-aside
   centring the portrait in the leftover width. */
.hero-body {
  display: flex;
  align-items: stretch;
  justify-content: flex-start;
  gap: clamp(24px, 3vw, 48px);
}

.hero-text {
  flex: 0 1 auto;
  min-width: 0;
}

/* Two jobs.

   It stretches to the height of the text beside it, which is what lets the
   portrait be sized as a proportion of that text rather than as a fixed
   number of pixels — change the headline, or let the standfirst wrap to
   another line, and the portrait follows.

   And it takes all the width left over beside the text, centring the portrait
   within it. The padding then biases that centre back toward the text: dead
   centre leaves the portrait stranded mid-page, while hard against the text
   crowds it. This sits between the two, and stays proportional on any width
   rather than being a pixel gap that only looks right on one screen. */
.hero-aside {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: 1;
  min-width: 0;
  padding-right: clamp(24px, 9vw, 170px);
}

.hero-top {
  display: flex;
  align-items: center;
  gap: 14px;
}

/* A frame with the photo inside it, rather than a round photo. The frame is
   the circle; the image is free to be scaled and slid around within it, which
   is what makes an ordinary square photo sit properly in a round hole. */
.hero-face {
  display: block;
  border-radius: 50%;
  overflow: hidden;
  background: var(--surface);
  flex: none;
  aspect-ratio: 1;
}

.hero-face img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform-origin: center;
}

/* Sized as a proportion of the text it sits next to, not as a fixed width.
   82% keeps it clearly subordinate to the words — a circle reads larger than
   its measurements because it's a solid bright shape against dark, so
   matching the text height exactly makes it look bigger than the text.

   The clamps are only guard rails for extremes: a very short headline
   shouldn't shrink it to nothing, and a long standfirst shouldn't let it
   grow past the point where it starts pushing the ticker off screen. */
.hero-face--lg {
  height: 82%;
  width: auto;
  min-height: 120px;
  max-height: 260px;
  aspect-ratio: 1;
}

/* On a narrow screen it rides the eyebrow line instead, which costs about
   thirty pixels of height — the point is to keep the work near the top. */
.hero-face--sm {
  width: 46px;
  display: none;
}

.hero .standfirst {
  margin-top: 28px;
  max-width: 46ch;
  font-size: var(--lead);
  line-height: 1.55;
  color: var(--ink-dim);
  text-wrap: pretty;
}

/* ----------------------------------------------------------------- marquee */

.marquee {
  margin-top: clamp(48px, 8vh, 88px);
  padding-block: 26px;
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
  overflow: hidden;
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
  mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
}

/* Driven from site.js rather than by a CSS animation. Hovering slows the
   strip instead of stopping it, and easing between two speeds is something
   an animation-duration swap cannot do — changing the duration recomputes
   the animation's progress and the strip visibly jumps. */
.marquee-track {
  display: flex;
  align-items: center;
  gap: clamp(44px, 6vw, 88px);
  width: max-content;
  will-change: transform;
}

/* --------------------------------------------------------------- logo mark */

/* Every logo is flattened to a single silhouette and repainted in the site's
   ink, so twelve brand guidelines end up looking like one set.

   This uses a filter on a plain <img> rather than a CSS mask. A mask would be
   the tidier tool, but mask images obey same-origin rules, and opening the
   site straight from disk (file://) gives every file its own opaque origin —
   which makes Chrome drop all of them and leaves the logos invisible.
   Filters have no such restriction, so this works from disk and from a host.

   brightness(0) crushes the artwork to black, invert(1) lifts it to white.
   Alpha is untouched throughout, so transparent stays transparent. */
.mark {
  display: inline-block;
  object-fit: contain;
  object-position: center;
  filter: brightness(0) invert(1);
  opacity: var(--logo-opacity);
  /* Brands arrive drawn at a dozen different sizes inside their own
     artboards — a tall monogram and a long wordmark given the same box still
     read as a jumble, because contain fits the artwork's bounds rather than
     its optical weight. Scaling inside the box leaves the box alone, so the
     spacing down the strip and across the wall stays even while each mark is
     balanced against its neighbours by eye. */
  transform: scale(var(--mark-scale, 1));
  transition: opacity 0.3s var(--ease);
}

.marquee .mark {
  width: 104px;
  height: 40px;
  flex: none;
  opacity: calc(var(--logo-opacity) * 0.75);
}

.marquee .mark-link {
  flex: none;
}

/* A mark becomes a link only when that client has a project behind it.
   Lifting to full opacity on hover is the whole affordance: the clients
   whose work can't be shown stay dim and inert, which is the honest signal
   and saves anyone a dead click. */
.mark-link {
  display: inline-flex;
}

.mark-link:hover .mark,
.mark-link:focus-visible .mark {
  opacity: 1;
}

.logowall .mark-link {
  display: block;
  width: 100%;
}

/* The strip drifts, and touch has no hover to pause it — a moving tap target
   is a bad one, so on touch the marquee goes back to being decoration. The
   same logos are tappable in the static wall further down. */
@media (hover: none) {
  .marquee .mark-link {
    pointer-events: none;
  }
}

/* --------------------------------------------------------------- work grid */

.work {
  padding-top: clamp(48px, 9vh, 110px);
  padding-bottom: clamp(60px, 10vh, 130px);
}

.grid {
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  gap: var(--gutter);
  align-items: start;
}

/* Deliberately NOT grid-auto-flow: dense.

   Dense backfills holes by pulling later tiles forward, which quietly
   reorders the work — a project sitting seventh in the Studio can render
   sixth on the page. The Studio promises that the order you drag is the
   order a visitor sees, and that promise is worth more than a filled hole.

   It also made the holes worse, not better: dropping a tall tile alongside
   two squares set the row's height by the tall one and left dead space
   under the squares. Without dense, a row that can't be filled simply ends
   early, which reads as whitespace rather than as a mistake. */

/* Sizing lives on .cell / .cell-face, not on .tile, so that project tiles and
   the cards you drop between them are measured by exactly the same rules and
   land level with each other. .tile and .card carry only their own looks. */
.cell {
  display: block;
  grid-column: span 2;
  position: relative;
}

.cell[data-size="full"] {
  grid-column: span 6;
}
.cell[data-size="wide"] {
  grid-column: span 4;
}

.cell-face {
  position: relative;
  overflow: hidden;
  background: var(--surface);
  border-radius: 2px;
  aspect-ratio: 1 / 1;
}

.cell[data-size="full"] .cell-face {
  aspect-ratio: 16 / 9;
}
.cell[data-size="wide"] .cell-face {
  aspect-ratio: 3 / 2;
}
.cell[data-size="tall"] .cell-face {
  aspect-ratio: 3 / 4;
}

/* Reserves the caption's height on every cell. A tile fills this with its
   title; a card leaves it empty. Without it a card would sit 38px shorter
   than the tile beside it and put the ragged row back. */
/* Flex, not a plain block. A block foot inherits a text strut from the body's
   17px/1.6, so a foot with a caption in it stands 27.2px tall while an empty
   one falls back to the 24px minimum — and a captioned cell ends up 3px taller
   than its neighbour. Flex items generate their own boxes, so the height comes
   from the caption alone and every foot in a row agrees. */
.cell-foot {
  display: flex;
  align-items: baseline;
  gap: 12px;
  margin-top: 14px;
  min-height: 24px;
}

/* Pinning the pair that is meant to read as level.

   A wide and a tall look like they should line up, and by pure ratio they
   would — but a span-4 tile contains three internal gutters where a span-2
   contains one, so the wide ends up exactly two thirds of a gutter taller.
   At the default 24px that is a 16px near-miss: too small to look deliberate,
   large enough to look like a mistake.

   So at the six-column layout both are given the same computed height instead
   of an aspect ratio. --u is the width of one column track, measured by
   site.js, because CSS cannot read it. Every other combination still lands
   wherever it lands — that stagger is the point of the design; it just has to
   be an obvious amount rather than an accidental one. */
@media (min-width: 1024px) {
  .cell[data-size="wide"] .cell-face,
  .cell[data-size="tall"] .cell-face {
    aspect-ratio: auto;
    height: calc((2 * var(--u, 0px) + var(--gutter)) * 4 / 3);
  }

  /* Falls back to the plain ratios if script hasn't measured yet. */
  .grid:not([style*="--u"]) .cell[data-size="wide"] .cell-face {
    aspect-ratio: 3 / 2;
    height: auto;
  }
  .grid:not([style*="--u"]) .cell[data-size="tall"] .cell-face {
    aspect-ratio: 3 / 4;
    height: auto;
  }
}

/* :not(.mark) matters — the client logo is also an <img> inside this box,
   and without it the cover rule stretches the logo to fill the whole panel. */
.cell-face img:not(.mark) {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: scale(1.001);
  transition: transform 0.75s var(--ease), filter 0.5s var(--ease);
}

.tile:hover .cell-face img:not(.mark) {
  transform: scale(1.035);
}

/* The client mark, worn in the corner of the panel. */
.tile-mark {
  position: absolute;
  top: 16px;
  left: 16px;
  width: 74px;
  height: 28px;
  z-index: 2;
  pointer-events: none;
  /* Repeats the recolour from .mark because filter does not accumulate —
     the shadow is chained on the end so it reads against a pale image. */
  filter: brightness(0) invert(1) drop-shadow(0 1px 8px rgba(0, 0, 0, 0.55));
}

.tile:hover .tile-mark {
  opacity: 1;
}

.tile-caption {
  display: flex;
  align-items: baseline;
  gap: 12px;
  margin-top: 14px;
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 0.6s var(--ease) 0.15s, transform 0.6s var(--ease) 0.15s;
}

.tile.is-in .tile-caption {
  opacity: 1;
  transform: none;
}

.tile-caption .t {
  font-size: 15px;
  font-weight: 500;
  letter-spacing: -0.01em;
}

.tile-caption .c {
  font-family: var(--label);
  font-weight: 500;
  font-size: 11px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-faint);
  margin-left: auto;
  white-space: nowrap;
}

/* ------------------------------------------------------------ previews */

/* The four visuals sit stacked; only one is ever shown. */
.tile-frame {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 0.14s linear;
}

.tile-frame.is-on {
  opacity: 1;
}

/* The segment bar is the entire affordance. Nobody discovers that a tile
   holds four visuals unless something says so, and at rest it has to be
   quiet enough not to compete with the image it sits on. */
.tile-steps {
  position: absolute;
  left: 16px;
  right: 16px;
  bottom: 12px;
  z-index: 2;
  display: flex;
  gap: 4px;
  opacity: 0.3;
  pointer-events: none;
  transition: opacity 0.3s var(--ease);
}

.tile:hover .tile-steps {
  opacity: 1;
}

.tile-steps i {
  flex: 1;
  height: 2px;
  border-radius: 1px;
  background: var(--ink);
  opacity: 0.35;
  transition: opacity 0.14s linear;
}

.tile-steps i.is-on {
  opacity: 1;
}

/* ---------------------------------------------------------------- cards */

/* No panel, no frame — the card sits directly on the page so it reads as a
   pause in the grid rather than another object in it. With the border gone
   the text has to carry the shape itself, so it centres both ways. */
.card-face {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  text-align: center;
  gap: 12px;
  padding: clamp(18px, 2.2vw, 32px);
  background: transparent;
  border: 0;
}

a.card:hover .card-line {
  color: var(--accent);
}

.card-line {
  font-size: clamp(1.1rem, 1.9vw, 1.9rem);
  font-weight: 500;
  line-height: 1.14;
  letter-spacing: -0.025em;
  text-wrap: balance;
  transition: color 0.25s var(--ease);
}

.card-sub {
  color: var(--ink-dim);
  font-size: 15px;
  line-height: 1.5;
  text-wrap: pretty;
}

.card-cta {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  margin-top: 6px;
  font-size: 14px;
  color: var(--ink-dim);
  transition: color 0.25s var(--ease);
}

a.card:hover .card-cta {
  color: var(--accent);
}

.card-cta em {
  font-style: normal;
  display: inline-block;
  transition: transform 0.3s var(--ease);
}

a.card:hover .card-cta em {
  transform: translateX(4px);
}

/* Kinetic type. Letters are displaced by their distance from the cursor.
   Deliberately transform-only — no variable-font weight — because only two
   static weights are requested from Google Fonts, and the effect has to work
   whichever of the five typefaces is chosen. */
.card-kinetic {
  display: flex;
  flex-wrap: wrap;
  font-size: clamp(1.5rem, 3.4vw, 3rem);
  font-weight: 500;
  line-height: 1.05;
  letter-spacing: -0.02em;
}

.card-kinetic span {
  display: inline-block;
  transition: transform 0.28s var(--ease);
}

.card-kinetic .sp {
  width: 0.3em;
}

/* ---------------------------------------------------------------- logowall */

.logowall {
  padding-block: clamp(56px, 9vh, 110px);
  border-top: 1px solid var(--line);
}

.logowall-grid {
  margin-top: 40px;
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
  gap: clamp(28px, 4vw, 56px) clamp(20px, 3vw, 40px);
  align-items: center;
}

.logowall .mark {
  width: 100%;
  height: 42px;
}


.logowall ul {
  list-style: none;
  margin: 0;
  padding: 0;
  display: contents;
}

/* ------------------------------------------------------------------ footer */

.site-footer {
  padding-block: clamp(56px, 9vh, 100px);
  border-top: 1px solid var(--line);
}

.footer-top {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  justify-content: space-between;
  gap: 32px;
}

.footer-mail {
  font-size: clamp(1.6rem, 4vw, 3rem);
  font-weight: 500;
  letter-spacing: -0.03em;
  transition: color 0.25s var(--ease);
}

.footer-mail:hover {
  color: var(--accent);
}

.footer-links {
  display: flex;
  gap: 22px;
  font-size: 14px;
  color: var(--ink-dim);
}

.footer-links a:hover {
  color: var(--ink);
}

/* The way back into your own editor. Small and quiet — it inherits the same
   framing as the hero portrait, so whatever zoom and position you set there
   applies here too. */
.studio-dot {
  display: inline-block;
  width: 22px;
  height: 22px;
  margin-left: 12px;
  border-radius: 50%;
  overflow: hidden;
  vertical-align: -6px;
  opacity: 0.35;
  background: var(--surface);
  transition: opacity 0.3s var(--ease);
}

.studio-dot:hover,
.studio-dot:focus-visible {
  opacity: 1;
}

.studio-dot img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.footer-bottom {
  margin-top: 48px;
  display: flex;
  flex-wrap: wrap;
  gap: 12px 24px;
  justify-content: space-between;
  font-family: var(--label);
  font-weight: 500;
  font-size: 11px;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

/* ------------------------------------------------------------ project page */

.proj-hero {
  padding-top: clamp(120px, 18vh, 190px);
}

.proj-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 14px 28px;
  align-items: center;
  margin-bottom: 20px;
}

.proj-hero h1 {
  max-width: 20ch;
  font-size: var(--h1);
  font-weight: 500;
  line-height: 1.02;
  letter-spacing: -0.03em;
  text-wrap: balance;
}

/* Covers arrive in every shape — a 16:9 still, a 3:4 poster, a square crop.
   Filling the width would make a portrait cover taller than the screen, so
   it is capped by height and centred on the surface instead. */
.proj-cover {
  margin-top: clamp(32px, 5vh, 56px);
  border-radius: 2px;
  overflow: hidden;
  background: var(--surface);
  display: flex;
  justify-content: center;
}

.proj-cover img {
  width: 100%;
  height: auto;
  max-height: 78vh;
  object-fit: contain;
}

.proj-body {
  padding-block: clamp(36px, 6vh, 72px);
}

.proj-writeup {
  max-width: 62ch;
}

/* Client / year / role read across as one line of credits under the opening
   image. Wraps to two on a narrow screen rather than squeezing. */
.proj-facts {
  display: flex;
  flex-wrap: wrap;
  gap: 20px clamp(32px, 6vw, 88px);
  padding-top: clamp(24px, 4vh, 40px);
  border-top: 1px solid var(--line);
  margin-top: clamp(28px, 5vh, 56px);
}

.fact .k {
  font-family: var(--label);
  font-weight: 500;
  font-size: 11px;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.fact .v {
  margin-top: 6px;
  font-size: 16px;
}

.proj-writeup p {
  font-size: var(--lead);
  line-height: 1.65;
  color: var(--ink-dim);
  text-wrap: pretty;
}

.proj-writeup p + p {
  margin-top: 1.2em;
}

/* Emphasis inside body copy. Bold lifts to full ink rather than only adding
   weight — on a dim paragraph, weight alone barely registers. */
.proj-writeup strong,
.info-bio strong {
  font-weight: 500;
  color: var(--ink);
}

.proj-writeup em,
.info-bio em {
  font-style: italic;
}

/* Relative, so it steps down from whatever it sits in rather than landing on
   a fixed size that fights the fluid scale everywhere else. */
.sm {
  font-size: 0.82em;
}

/* A gallery item is a grid cell exactly like a project tile, so a project
   page tiles by the same rules as the homepage — including the wide/tall
   pinning — and there is only one layout system in the whole site. */
.shot {
  margin: 0;
  cursor: zoom-in;
}

.shot .cell-foot .t {
  font-size: 14px;
  color: var(--ink-dim);
}

/* ------------------------------------------------------------------ video */

.cell-face video,
.proj-cover video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  background: var(--surface);
}

.proj-cover video {
  height: auto;
  max-height: 78vh;
  object-fit: contain;
}

video.tile-frame {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 0.14s linear;
}

video.tile-frame.is-on {
  opacity: 1;
}

.proj-next {
  border-top: 1px solid var(--line);
  padding-block: clamp(40px, 7vh, 80px);
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 20px;
}

.proj-next .n {
  font-size: clamp(1.4rem, 3.4vw, 2.4rem);
  font-weight: 500;
  letter-spacing: -0.03em;
  transition: color 0.25s var(--ease);
}

.proj-next:hover .n {
  color: var(--accent);
}

/* --------------------------------------------------------------- info page */

.info {
  padding-top: clamp(130px, 20vh, 210px);
  padding-bottom: clamp(56px, 9vh, 110px);
  display: grid;
  grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
  gap: clamp(32px, 6vw, 90px);
  align-items: start;
}

.info-portrait {
  border-radius: 2px;
  overflow: hidden;
  background: var(--surface);
}

.info h1 {
  font-size: var(--h1);
  font-weight: 500;
  line-height: 1.03;
  letter-spacing: -0.03em;
  margin-bottom: 26px;
}

.info-bio p {
  font-size: var(--lead);
  line-height: 1.65;
  color: var(--ink-dim);
  text-wrap: pretty;
}

.info-bio p + p {
  margin-top: 1.15em;
}

.info-looking {
  margin-top: 34px;
  padding: 22px 24px;
  border: 1px solid var(--line);
  border-radius: 2px;
  background: var(--surface);
}

.info-looking p {
  margin-top: 8px;
  color: var(--ink-dim);
}

.btn {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  margin-top: 30px;
  padding: 13px 22px;
  border: 1px solid var(--line);
  border-radius: 999px;
  font-size: 14px;
  transition: border-color 0.25s var(--ease), color 0.25s var(--ease);
}

.btn:hover {
  border-color: var(--accent);
  color: var(--accent);
}

/* ---------------------------------------------------------------- lightbox */

.lightbox {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: grid;
  place-items: center;
  padding: 4vmin;
  background: rgba(8, 8, 8, 0.94);
  backdrop-filter: blur(6px);
  -webkit-backdrop-filter: blur(6px);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.3s var(--ease), visibility 0.3s;
  cursor: zoom-out;
}

.lightbox.is-open {
  opacity: 1;
  visibility: visible;
}

.lightbox img,
.lightbox video {
  max-width: 100%;
  max-height: 92vh;
  /* contain, not cover — the grid crops to keep the layout even, and this is
     the one place the piece is shown at the shape it was actually made in. */
  object-fit: contain;
}

/* The overlay closes on click, so the cursor says zoom-out. Over the video it
   would be lying: that is where the controls are, and clicking them keeps it
   open. */
.lightbox video {
  cursor: auto;
  background: #000;
}

.lightbox[hidden] {
  display: none;
}

/* Clear of the video's own control bar, and of a phone's status bar. */
.lb-close {
  position: fixed;
  top: max(1rem, env(safe-area-inset-top));
  right: max(1rem, env(safe-area-inset-right));
  z-index: 2;
  width: 42px;
  height: 42px;
  display: grid;
  place-items: center;
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.1);
  backdrop-filter: blur(4px);
  -webkit-backdrop-filter: blur(4px);
  color: #fff;
  cursor: pointer;
  transition: background 0.25s var(--ease);
}

.lb-close svg {
  width: 18px;
  height: 18px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
}

.lb-close:hover {
  background: rgba(255, 255, 255, 0.22);
}

.lb-close:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* ------------------------------------------------------------ gallery media */

/* Rows of shapes, not a grid of boxes. See fitGallery() in site.js for the
   reasoning; the mechanism is that --r (an item's width ÷ its height) drives
   both how much room it starts with and how much of the leftover it takes.
   Because the two are in the same proportion, everything in a row resolves to
   one height without any of it being stretched or trimmed.

   --row is the height a row aims for. Lowering it fits more across, which is
   the dial to reach for when a project has five or six pieces in it. */
.grid-justified {
  --row: clamp(140px, 20vh, 240px);
  display: flex;
  flex-wrap: wrap;
  gap: var(--gutter);
  align-items: flex-start;
}

/* Overrides the column spans, which no longer decide anything here — the
   shape of the piece does. */
.grid-justified .cell,
.grid-justified .cell[data-size] {
  --r: 1.7778; /* 16:9 until the real thing has been measured */
  grid-column: auto;
  flex: var(--r) 1 calc(var(--r) * var(--row));
  /* A last row with one or two pieces in it has a lot of spare width, and
     without a ceiling they would inflate to swallow it — a final item twice
     the height of everything above it, purely because of what it followed.
     Capped close to the row height, so the last row runs slightly short
     instead, which reads as the end of a sequence rather than a mistake. */
  max-width: min(100%, calc(var(--r) * var(--row) * 1.35));
}

.grid-justified .cell-face,
.grid-justified .cell[data-size] .cell-face {
  aspect-ratio: var(--r);
  width: 100%;
  background: #000;
}

/* Until a piece has been measured its cell is a 16:9 guess, so it fades in
   rather than visibly snapping to its real shape. */
.grid-justified .cell {
  transition: opacity 0.3s var(--ease);
}
.grid-justified .cell:not(.is-measured) {
  opacity: 0;
}

@media (prefers-reduced-motion: reduce) {
  .grid-justified .cell:not(.is-measured) {
    opacity: 1;
  }
}

.shot .cell-face video,
.shot .cell-face img:not(.mark) {
  object-fit: contain;
}

/* Stills still open on click; a clip has its own controls, so it doesn't. */
.shot .cell-face img:not(.mark) {
  cursor: zoom-in;
}

.shot .cell-face img:not(.mark):focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* Enlarging a clip needs its own control: the video's own surface belongs to
   the play button and the scrubber now, and a click can only mean one thing.
   Sits clear of the control bar, which browsers draw along the bottom. */
.cell-face .expand {
  position: absolute;
  top: 10px;
  right: 10px;
  z-index: 3;
  width: 32px;
  height: 32px;
  display: grid;
  place-items: center;
  padding: 0;
  border: 0;
  border-radius: 4px;
  background: rgba(10, 10, 10, 0.55);
  backdrop-filter: blur(4px);
  -webkit-backdrop-filter: blur(4px);
  color: #fff;
  cursor: pointer;
  opacity: 0;
  transition: opacity 0.25s var(--ease), background 0.25s var(--ease);
}

.cell-face .expand svg {
  width: 15px;
  height: 15px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
}

/* Revealed on approach, and always present for a keyboard — an control that
   only exists on hover is one a keyboard can never reach. */
.shot:hover .expand,
.cell-face .expand:focus-visible {
  opacity: 1;
}

.cell-face .expand:hover {
  background: rgba(10, 10, 10, 0.8);
}

.cell-face .expand:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

@media (hover: none) {
  /* Nothing to hover on a phone, so it simply stays visible. */
  .cell-face .expand { opacity: 1; }
}

/* ----------------------------------------------------------------- reveals */

[data-reveal] {
  opacity: 0;
  transform: translateY(22px);
  transition: opacity 0.85s var(--ease), transform 0.85s var(--ease);
}

[data-reveal].is-in {
  opacity: 1;
  transform: none;
}

/* --------------------------------------------------------------- responsive */

/* Laptop-height windows.

   The ticker is the first sign the page has any depth to it, and on a 13" or
   14" MacBook it was landing just past the fold — so the one thing telling a
   visitor to keep going was the thing they couldn't see.

   Height is what varies here, not width, so the rhythm tightens on short
   viewports rather than at some width breakpoint. A 27" display keeps the
   generous version. The portrait shrinks too: it is the tallest thing in the
   hero, so it sets how far down everything below it starts. */
@media (max-height: 860px) {
  .hero {
    padding-top: clamp(112px, 15vh, 140px);
    padding-bottom: clamp(28px, 4vh, 40px);
  }
  .hero .standfirst {
    margin-top: 22px;
  }
  /* On a laptop the portrait costs no height at all until it grows past the
     text block beside it, so it only needs a ceiling, not a smaller size.
     Width is left alone here — it is derived from the height now. */
  .hero-face--lg {
    max-height: 230px;
  }
  .marquee {
    margin-top: clamp(28px, 4vh, 40px);
  }
}

@media (max-width: 767px) {
  .hero-face--lg {
    display: none;
  }
  .hero-face--sm {
    display: block;
  }
  .hero-aside {
    display: none;
  }
}

@media (max-width: 1023px) {
  .grid {
    grid-template-columns: repeat(4, 1fr);
  }
  .cell[data-size="full"],
  .cell[data-size="wide"] {
    grid-column: span 4;
  }
  .proj-body,
  .info {
    grid-template-columns: minmax(0, 1fr);
  }
  .info-portrait {
    max-width: 340px;
  }
}

@media (max-width: 639px) {
  body {
    font-size: 16px;
  }
  .grid {
    grid-template-columns: 1fr;
  }
  .cell,
  .cell[data-size="full"],
  .cell[data-size="wide"] {
    grid-column: span 1;
  }
  /* On a phone the captions never hide — there is no hover to reveal them,
     and a recruiter scrolling one-handed should not have to hunt. */
  .tile-caption {
    opacity: 1;
    transform: none;
  }
  .shot:nth-child(3n + 1) {
    grid-column: span 1;
  }
  .site-nav {
    font-size: 13px;
  }
  .site-header {
    padding: 14px var(--pad);
  }
  /* Name plus discipline plus three nav items will not fit across a phone —
     the discipline is the part a visitor can lose without missing anything. */
  .brand span {
    display: none;
  }
  /* The bar sits lower and wider on a phone, where the tile is full width
     and the scrub is driven by scrolling rather than hovering. */
  .tile-steps {
    left: 12px;
    right: 12px;
    bottom: 10px;
    opacity: 0.55;
  }
}

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
  [data-reveal],
  .tile-caption {
    opacity: 1 !important;
    transform: none !important;
    transition: none !important;
  }
  .marquee-track {
    animation: none;
  }
  .tile:hover .cell-face img {
    transform: none;
  }
  /* Frames still switch on a desktop, because that is the visitor's own
     cursor doing it — but they cut rather than crossfade, and the phone's
     scroll-driven scrub is switched off entirely in site.js. */
  .tile-frame {
    transition: none;
  }
  .card-kinetic span {
    transition: none;
    transform: none !important;
  }
}
