/* ==========================================================================
   Ellis — project case study
   Desktop  : Figma node 13:3027 (1440 x 10206)
   Nav open : node 91:3226   Nav collapsed: node 13:3055
   Narrow   : node 6:2579 (393 wide)

   Placement below is the desktop frame's own coordinates, expressed in `--u`
   — one comp pixel, which resolves to exactly 1px at a 1440 viewport and
   scales with viewport width either side of it.
   ========================================================================== */

@font-face {
  font-family: "Edict Display";
  src: url("assets/fonts/EdictDisplay-Regular.woff2") format("woff2"),
       url("assets/fonts/Edict%20Display%20Regular.ttf") format("truetype");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Aeonik Mono";
  src: url("assets/fonts/AeonikMono-Regular.woff2") format("woff2"),
       url("assets/fonts/AeonikMono-Regular.otf") format("opentype");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/* Two things said to the browser before anything is drawn.

   Nothing on this site is meant to be read sideways: the bands run to the
   edges and stop. Something overflowing by a pixel is a bug, and left to
   stand it is a bug that compounds — a scrollable document is a wider
   document, and a wider document is one iOS zooms out to fit, taking the
   whole page away from the right edge and inflating the type to compensate.
   `clip` and not `hidden`, so no scrollport is made and sticky goes on
   sticking.

   And the type is the size it is set at. Safari on a phone otherwise decides
   for itself that some blocks are too wide to be read at the size they ask
   for and enlarges their text — which is why the contact sheet's labels and
   fields came up half again as large as the same labels on the page behind
   it. A page that is laid out to a frame does not want a second opinion. */
html {
  /* `hidden` and not `clip`, which is the other way round from every other
     place on this site that trims a horizontal overflow.

     Those are ordinary boxes, where `clip` is right because it trims without
     making a scrollport and a sticky child goes on sticking. This is the
     root, where the overflow is handed to the viewport instead — and Safari
     does not hand `clip` over. So on a phone the page could still be pushed
     sideways: the whole thing slid under the thumb, with the paper showing
     down one edge, which is what it was doing in the recording. `hidden`
     propagates, and propagating is the entire job here. Sticky is unharmed
     because the scroller it sticks against is the viewport either way. */
  overflow-x: hidden;
  overflow-y: visible;
  /* And no rubber band on that axis either, for the overscroll that is not
     overflow at all. */
  overscroll-behavior-x: none;
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

:root {
  /* The gutter is the frame's 20 at every desktop width; only what sits
     inside it scales. Frame coordinates are a plain multiple of --u, so the
     page carries the difference between the two as a nudge — a coordinate of
     20 then lands on 20 on screen, whatever the width. Full-bleed bands take
     the nudge back out. */
  --pad: 20px;
  /* The width the page actually has, which is not what `100vw` reports.

     `100vw` is the viewport including a classic scrollbar, so on any desktop
     set to show its scrollbars the whole design was laid out two dozen
     pixels wider than the box it had — and on iOS it is the width of a
     document that has become horizontally scrollable, which is worse: wider
     document, wider `--content`, wider still, until Safari gives up and
     zooms the page out to fit. That is a page sitting short of the right
     edge with its type inflated, and it is self-inflicted.

     `--vw` is the document element's own client width, written by app.js;
     `100vw` stands in for the frame before it has run, and on anything that
     cannot run it, where it is the same value it always was. */
  --content: calc(var(--vw, 100vw) - 2 * var(--pad));
  --u: max(0.62px, calc(var(--content) / 1400));
  --o: calc(var(--pad) - 20 * var(--u));
  /* What stands between the end of a page and the top of the footer. One
     number for the whole site, flat rather than scaled: it is a held breath
     before the foot of the page and not a measure off the frame, so it is
     the same 200 on a phone as on a desktop. Spent at one boundary — the
     foot of the page, in `.shell > .page` — and nowhere else, so no page
     can add to it. */
  --footer-content-gap: 200px;
  --gutter: calc(10 * var(--u));
  --col: calc((var(--content) - 11 * var(--gutter)) / 12);

  /* 10 and 14 are the sizes the narrow frames are set at, and the floor for
     the wide ones — so the type is the same either side of the breakpoint. */
  --fs-label: clamp(10px, 0.6944vw, 13px);
  --fs-body: clamp(14px, 0.9722vw, 18.2px);
  --fs-title: clamp(38px, 4.1667vw, 78px);

  --bg: #f3f3f3;
  --cream: #f6f0d5;
  --hero-from: #eef2f2;
  --hero-to: #e7edec;
  --ink: #000;
  --ink-copy: #0c0c0c;
  --paper: #fff;
  --mist: #dfe8ec;
  --rule: rgba(0, 0, 0, 0.2);
  /* Nav panels: black at 5% where they sit on a white page, white where
     they sit over colour or imagery. */
  /* The nav's own ground, what a row lifts to under the pointer, and what
     the words on that row become. Each page sets its own three further down:
     a nav has to read against whatever it is floating over, so there is no
     one set that serves them all.

     None of them is see-through. A ground at five percent of black is the
     same grey as #f2f2f2 on a white page, but it is a grey that changes with
     whatever passes beneath it — and the nav floats over the page. */
  --nav-panel-on-light: #f2f2f2;
  --nav-item-hover: var(--ink);
  --nav-item-hover-ink: var(--paper);
  --nav-panel-on-media: #ffffff;
  --spec: rgba(0, 0, 0, 0.5);
  --dim: rgba(0, 0, 0, 0.3);

  /* The site nav's own comp pixel: tracks the page's until the page would
     take the 226 panel below its frame size, then holds. Anything that hangs
     off the nav — the work page's rail — measures in it too. */
  --snav-w: max(calc(226 * var(--u)), 226px);
  --snav-u: calc(var(--snav-w) / 226);
  /* The space between the nav's own boxes — a line's width, so the stack
     reads as one object divided rather than as separate cards. */
  --snav-gap: 1px;
  /* One beat of the nav's stagger: each box waits a step longer than the one
     before it, so the nav arrives and leaves as a sequence rather than all
     at once. */
  --snav-step: 55ms;

  /* The contact sheet coming in from the right. Longer than the nav's fold:
     it crosses half the window rather than a panel's height. */
  --cpanel-ms: 420ms;

  /* What the fold takes, and what the contents do inside it.

     A drawer is a thing you watch close. The fold and the fade used to run
     to one clock and one curve, which is matched on paper — what is left of
     the contents and what is left of the box the same number at every
     instant — and wrong in the eye, because opacity reads far faster than
     height. Half faded is gone; half closed is a drawer standing open. So
     the box went first and the drawer arrived at an empty one.

     The contents hold now, and follow: full strength while the box takes its
     first stretch, then away over the rest of it, landing together. Given as
     tokens so reduced motion can take the clock out from under all of it at
     once — the rules below are specific enough that a duration set on them
     from a media query does not reach. */
  --snav-fold-ms: 440ms;
  /* Opening is its own number. The two directions are not the same errand:
     folding is a thing the reader watches happen to the nav while they are
     reading past it, and wants the time; opening is the nav answering a
     scroll back up, and an answer that takes as long as the question reads
     as hesitation. A quarter off, which is where it stood before all this. */
  --snav-open-ms: 370ms;
  /* One layer behind the next. The nav is a stack, and a stack goes in an
     order — the panel, then the menu under it — rather than everything at
     once. Short enough that the second is away before the first has finished
     and the two read as one movement passing down the column. */
  --snav-order: 90ms;
  /* How long the whole stack takes to fold: the last layer's beat plus its
     own travel. Anything that has to outlast the fold counts from here. */
  --snav-fold-all: calc(var(--snav-order) + var(--snav-fold-ms));
  /* The panel's ground leaving: the last stretch of the fold, where it masks
     the final 48 away and fades, both on this one clock. */
  --snav-fill-ms: 230ms;

  --serif: "Edict Display", ui-serif, "Iowan Old Style", "Times New Roman", Times, serif;
  --mono: "Aeonik Mono", ui-monospace, "SF Mono", SFMono-Regular, Menlo, monospace;

  /* One leading per typeface, everywhere. */
  --lh-mono: 1.2;
  --lh-serif: 1.5;
  /* The frame is drawn on capitals — every measurement in it is from the top
     of one set of letters to the top of the next — so copy that faces a label
     or a rule is trimmed to its own, below. What is left to settle is that a
     label's capitals are shorter than a line's, and this is half that
     difference: the nudge that puts a label's centre on the centre of the
     single line beside it. In the faces' own proportions (the serif caps at
     0.684 of its size, the mono at 0.700) so it holds at either end of the
     clamp.

     Only where a label faces one line. Facing a paragraph it sits level with
     the top of the first, which is what a heading beside a block of text
     does, and needs nothing. */
  --serif-cap: calc(0.684 * var(--fs-body));
  /* The plus on an Offering or Credits row used to stand 16 tall and set the
     height of the row it sat on, which left this much of it above the line
     and the same below. Taking it out of the flow took that out of the pitch
     with it, and the lists closed up. It is given back between the rows
     rather than inside them, so they sit apart as they did while the first
     one still starts level with the eyebrow beside it. */
  /* The air over a line in a list, and under it — the home page's four rows,
     and Offering, Credits, Associates and Benefits on About. Each had its own
     figure before, and all of them scaled with the frame besides: 19 to 23 at
     1440, 13 to 15 at 1024, 26 to 32 at 1920.

     It is the same figure a rule gives what it heads, written as that figure
     rather than as a copy of it. A rule is a rule: the one over a block and
     the one between two lines of it are the same line doing the same job, and
     a list whose first row answered to one and whose rest answered to the
     other read as an error in the first row. Named twice because the two
     places are worth telling apart when reading, and aliased so they cannot
     drift apart without somebody meaning it. */
  --list-air: var(--rule-gap);

  /* And the air under a rule that heads a block, which is a different job:
     the line over Define, over Info, over Post Scriptum's opening, and over
     each of the three lists at the foot of the home page. Tighter than the
     air inside a list, where a rule stands between two lines and has to give
     each of them room. */
  --rule-gap: 22px;
  /* The ground a clickable row takes under the pointer, and what its words
     turn to over it. A row that goes somewhere says so by lighting up; one
     that opens in place has its plus, and one with a face has the portrait
     riding the cursor. */
  --hov-ground: var(--ink);
  --hov-ink: var(--paper);
  /* The ground starts where the row starts — 846:51956 and 846:52144 both
     set its left edge on the column's, so a hovered row lines up with the
     rows above and below it rather than stepping out of the list.

     What moves instead is the writing: a name comes in off the edge of its
     own ground by ten. A picture does not — it is the edge, and 846:52144
     draws it flush. The right-hand side is not an edge at all; the ground
     runs off the screen, so it is given more than it can need and trimmed
     by the page. */
  --hov-indent: 10px;
  --hov-run: calc(-1 * var(--pad) - 24px);
  --label-nudge: calc(0.5 * (var(--serif-cap) - 0.7 * var(--fs-label)));
  --ease: cubic-bezier(0.4, 0, 0.2, 1);

  /* A card under the pointer — see `.card__link::before`. The shrink is the
     frame's 10 off its 381.667 read as a proportion; the beat is its own,
     since the frame draws the state and not the way into it. The inset the
     shrink leaves is not a figure of its own — it is measured off the card
     at `.card__link`, which is the only way the three sides agree. */
  --card-shrink: 0.9476;
  --card-hover-ms: 240ms;
  /* Settles rather than stops — for the nav's fold, which is a long move. */
  --ease-soft: cubic-bezier(0.25, 1, 0.3, 1);
  /* Leaves quickly and settles for a long time — what makes a line of type
     look like it is arriving rather than being moved. */
  --ease-reveal: cubic-bezier(0.16, 1, 0.3, 1);
  /* What the long moves take: the grids of pictures, and the nav crossing the
     window between one page and the next.

     Its virtue is an even middle, which is a harder thing to get than either
     end. A curve can leave promptly and still arrive badly: the cubic this
     replaces put half the distance into the first fifth of the time and then
     spent the whole second half creeping the last eighth. Nothing is wrong
     with either end of that — but the eye follows the edge of a wipe, and an
     edge that decelerates that hard reads as the movement hanging halfway,
     which is the part that felt slow. This one covers roughly a third, a
     quarter, a fifth, a seventh of the way across in each fifth of its time:
     off without hesitating, steady through the middle where it is actually
     being watched, and easing only at the very end. */
  --ease-glide: cubic-bezier(0, 0, 0.58, 1);

  /* Folding. An even middle again, but eased at both ends rather than
     leaving at speed: a drawer is pushed, it does not spring. Where glide
     spends a quarter of its travel in the first sixth of the time, this
     spends a tenth, and keeps a third for each of the two after it —
     10, 33, 31, 17, 8, 2.

     Opening keeps glide: the panel coming out has nothing on screen to
     compete with and wants to be there promptly.

     How fast a move feels is its fastest moment, not its length — and a
     curve with a strong middle has a faster one than a front-loaded curve of
     the same duration. Over the fold's 170: glide at 420 peaked at 630 a
     second, and a hard middle at 360 peaked at 1080, which is why shortening
     it made it quicker in the hand as well as on the clock. Spread wider and
     given the time, this peaks at 590. */
  --ease-drawer: cubic-bezier(0.35, 0, 0.3, 1);
  /* What the contents leave on: nothing for a moment, then all of it. The
     mirror of glide, so what the box does going one way the fade does
     coming back. */
  --ease-hold: cubic-bezier(0.42, 0, 1, 1);
  /* The panel's ground leaving under its own closing edge. Nearly a hold at
     the start — it is still the ground while anything is standing on it —
     and then clear well before the edge lands, so the last pixel of the
     panel is not something you watch go. Held to the end it was still at a
     quarter strength with four fifths of a pixel left, which is exactly the
     edge disappearing. */
  --ease-fill: cubic-bezier(0.4, 0, 0.5, 1);

  /* What the copy arrives on, and how far apart two blocks of it are dealt.
     Tokens for the same reason the pictures' are: a page sets the rhythm, and
     everything on it keeps to that rhythm. The words used to be fixed here at
     950ms on the reveal curve whatever page they were on, so tuning a grid
     only ever tuned half of it — the cards settled and their names went on
     arriving for another half second afterwards, on a different curve, a
     different clock and a different stagger. Two things moving to two
     rhythms is most of what reads as a page not being smooth. */
  /* Short enough to be over before the next one starts.

     A mask that wipes is not a word that rises. A rise you can follow —
     something moves, and the eye reads the order off the movement — so 950
     against a line every 85 was eleven lines travelling at once and it still
     read as a sequence. A wipe gives the eye nothing to follow but its own
     edge, and eleven of those at once is a fog lifting off the page rather
     than an order. Shorter, and dealt further apart: two and a half in the
     air instead of eleven, and a line is plainly finishing as the next
     begins. The paragraph takes the same time it always did. */
  --rv-text-ms: 380ms;
  /* A line has further to travel than a word and less to say while it does,
     so it goes a little quicker than the copy beside it. */
  --rv-rule-ms: 460ms;
  /* An even middle, not an expo out. The curve this replaces put three
     quarters of a move into its first fifth, which on a word rising into its
     mask reads as crisp and on an edge wiping across one reads as a flash
     and then nothing — the travel is the whole of what there is to see now,
     so it is spread across the time it is given. The two pages that had
     already moved to a short reveal had moved to this curve with it. */
  --ease-text: var(--ease-glide);
  /* ms between one piece of text and the next — a row of a list and a block
     of copy alike, since they arrive in one order and keep one beat. */
  --block-step: 70;
  /* And between one line of a paragraph and the next. Longer than the step
     between blocks: blocks stand apart on the page and can overlap without
     confusing anything, while lines are stacked on each other and cannot. */
  --line-step: 120;
  /* The pictures' own curve, and not the copy's. The copy's is an expo out:
     over three quarters of the way in a fifth of its time. On a word rising
     twelve pixels into its mask that reads as crisp, which is why the text
     keeps it. On a mask crossing the whole height of a hero it reads as a
     snap followed by a long invisible crawl — 90% of a stated 1200ms was
     spent in 396 of them. Work and Post Scriptum had each already moved
     their pictures off it for their own grids; this is the same move, made
     once, for the pages that had not. */
  --ease-media: var(--ease-glide);
}

*, *::before, *::after { box-sizing: border-box; }
html { background: var(--bg); }

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--serif);
  -webkit-font-smoothing: antialiased;
}

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

.page {
  position: relative;
  left: var(--o);
  top: var(--o);
  width: calc(100% - var(--o));
  /* clip, not hidden: hidden would make the page the scroll container for
     the nav's sticky, which then anchors to the page rather than the window. */
  overflow: clip;
}

.page--ellis { height: calc(10206 * var(--u) + var(--o)); }
/* As long as the footer band's own place plus the band, so the page ends
   where the page ends however far down the band has had to go. */
/* Its height is what is on it. Everything above the work is absolute and
   hangs off the frame's own coordinates; everything from the work down is in
   flow, so the page ends where the last section does and nothing has to be
   told in advance how tall the list of work will be. */
/* `flow-root`, not plain block: the stack below the photograph carries its
   place on the page as a top margin, and a top margin on the first child of
   a block with nothing above it collapses out through the parent — so the
   page itself moved down by 1409 and everything absolute on it went along.
   A flow root keeps its children's margins inside it. */
.page--home {
  height: auto;
  display: flow-root;
  background: var(--paper);
  /* The hover ground on the lists is not given a width that stops at the
     measure — it is handed more than it can need and trimmed here, so it
     reaches the edge of the screen at every width instead of landing a
     pixel or two short of it on the rounding of the row it belongs to.

     `clip` rather than `hidden`: clip trims without making a scrollport, so
     nothing gains a scrollbar and a sticky child goes on sticking. Leaving
     the y axis visible is a pair the spec allows beside clip, where beside
     `hidden` it would have been turned into a scroller. */
  overflow-x: clip;
  overflow-y: visible;
}

/* Every band is placed against the frame's coordinates. */
.b { position: absolute; }

/* --- Type ---------------------------------------------------------------- */

.label {
  margin: 0;
  font-family: var(--mono);
  font-size: var(--fs-label);
  line-height: var(--lh-mono);
  letter-spacing: 0.01em;
  text-transform: uppercase;
  color: var(--ink);
  text-box: trim-both cap alphabetic;
}

.body {
  margin: 0;
  font-family: var(--serif);
  font-size: var(--fs-body);
  line-height: var(--lh-serif);
  letter-spacing: 0.01em;
  color: var(--ink-copy);
  overflow-wrap: break-word;
  text-box: trim-both cap alphabetic;
}

.body--wide { letter-spacing: 0.02em; }

/* --- Narrow-screen navigation (hidden on desktop) ------------------------ */

.mnav { display: none; }

/* --- Hero ---------------------------------------------------------------- */

.hero {
  left: calc(20 * var(--u));
  top: calc(20 * var(--u));
  width: calc(1400 * var(--u));
  height: calc(540 * var(--u));
  overflow: hidden;
  background: linear-gradient(to bottom, var(--hero-from), var(--hero-to));
}

.hero__statue {
  position: absolute;
  left: 30.214%;
  top: -10.741%;
  width: 39.643%;
  height: 155.185%;
}

.hero__clip { position: absolute; inset: 0; overflow: hidden; pointer-events: none; }

.hero__clip img {
  position: absolute;
  left: 4.12%;
  top: 2.88%;
  width: 91.89%;
  height: 97.18%;
  max-width: none;
}

.fold-sentinel { position: absolute; top: calc(560 * var(--u)); width: 100%; height: 1px; }

/* --- Copy blocks --------------------------------------------------------- */

.blk { border-top: 0.5px solid var(--rule); }
.blk__row { display: flex; gap: var(--gutter); align-items: flex-start; }
.blk__row--split { justify-content: space-between; }

.blk--intro {
  left: calc(20 * var(--u));
  top: calc(570 * var(--u));
  width: calc(695 * var(--u));
  padding-top: calc(20 * var(--u));
}

.blk--intro .blk__row { gap: calc(19 * var(--u)); }

.title {
  flex: 0 0 auto;
  width: calc(333 * var(--u));
  height: calc(40 * var(--u));
  margin: 0;
  font-family: var(--serif);
  font-size: var(--fs-title);
  font-weight: 400;
  line-height: var(--lh-serif);
  color: var(--ink-copy);
  text-box: trim-both cap alphabetic;
}

.title__mark { display: none; }

.scope {
  flex: 1 1 0;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: calc(20 * var(--u));
}

.scope .label { width: calc(98 * var(--u)); }

.blk--about {
  left: calc(725 * var(--u));
  top: calc(570 * var(--u));
  width: calc(695 * var(--u));
  padding-top: calc(20 * var(--u));
}

.blk--about .label { flex: 0 0 auto; width: calc(353 * var(--u)); }
.blk--about .body { flex: 1 1 0; min-width: 0; }

.blk--mark {
  left: calc(20 * var(--u));
  top: calc(856 * var(--u));
  width: calc(578 * var(--u));
  height: calc(540 * var(--u));
  display: flex;
  flex-direction: column;
  gap: calc(30 * var(--u));
  align-items: flex-start;
  justify-content: flex-end;
  padding: calc(20 * var(--u)) 0 calc(60 * var(--u));
}

.blk--mark .label { width: calc(166.25 * var(--u)); }
.blk--mark .body { width: calc(353 * var(--u)); }

.blk--approach {
  border-top: 0;
  left: calc(1078 * var(--u));
  top: calc(1820 * var(--u));
  width: calc(340 * var(--u));
}

/* Sidebar copy blocks, all bottom-aligned against a rule. */
.blk--grid,
.blk--colors,
.blk--patterns,
.blk--end {
  left: calc(20 * var(--u));
  width: calc(460 * var(--u));
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  justify-content: flex-end;
  padding: calc(20 * var(--u)) 0 calc(60 * var(--u));
}

.blk--grid { top: calc(4060 * var(--u)); }
.blk--colors { top: calc(6304 * var(--u)); height: calc(274 * var(--u)); }
.blk--patterns { top: calc(8117 * var(--u)); }
.blk--end { top: calc(9043 * var(--u)); }

.blk--grid .label { width: calc(109 * var(--u)); }
.blk--colors .label,
.blk--patterns .label,
.blk--end .label { width: calc(108 * var(--u)); }

.blk--grid .body,
.blk--colors .body,
.blk--patterns .body,
.blk--end .body { width: calc(342 * var(--u)); }

.blk--cards {
  left: calc(960 * var(--u));
  top: calc(7161 * var(--u));
  width: calc(460 * var(--u));
  padding: calc(20 * var(--u)) 0;
}

.blk--cards .label { flex: 0 0 auto; width: calc(118 * var(--u)); }
.blk--cards .body { flex: 1 1 0; min-width: 0; }

/* --- Tiles --------------------------------------------------------------- */

.tile { margin: 0; overflow: hidden; }
.tile__art { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); height: auto; }
.tile__tl { position: absolute; left: calc(20 * var(--u)); top: calc(20 * var(--u)); white-space: pre; }
.tile__tr { position: absolute; right: 45.813%; top: calc(20 * var(--u)); text-align: right; white-space: nowrap; }

/* Anchored off the bottom-right corner, as the frame draws it. */
.tile__stamp {
  position: absolute;
  right: 11.823%;
  bottom: 5%;
  transform: translate(100%, 100%);
  color: var(--paper);
  white-space: nowrap;
}

.tile--current {
  left: calc(608 * var(--u));
  top: calc(856 * var(--u));
  width: calc(812 * var(--u));
  height: calc(540 * var(--u));
  aspect-ratio: 812 / 540;
  background: var(--ink);
}

.tile--current .tile__art--wordmark { width: 15.977%; }

.tile--gridlayout {
  left: calc(608 * var(--u));
  top: calc(3516 * var(--u));
  width: calc(812 * var(--u));
  height: calc(740 * var(--u));
  aspect-ratio: 812 / 740;
  background: var(--paper);
}

.tile--gridlayout .tile__art--gridlayout { width: 24.384%; mix-blend-mode: difference; }
.tile--gridlayout .tile__stamp { bottom: 3.649%; }

.tile--apptablet {
  left: calc(20 * var(--u));
  top: calc(4336 * var(--u));
  width: calc(1400 * var(--u));
  height: calc(700 * var(--u));
  aspect-ratio: 2 / 1;
}

.tile--apptablet img { width: 100%; height: 100%; object-fit: cover; }

.tile--cardiso {
  left: calc(20 * var(--u));
  top: calc(6661 * var(--u));
  width: calc(813 * var(--u));
  height: calc(740 * var(--u));
  aspect-ratio: 813 / 740;
  background: var(--mist);
}

.tile--cardiso .tile__art--cardiso {
  width: 54.859%;
  transform: translate(calc(-50% + 1px), calc(-50% + 0.5px));
  mix-blend-mode: difference;
}

.tile--statuetablet {
  left: calc(843 * var(--u));
  top: calc(6661 * var(--u));
  width: calc(575 * var(--u));
  height: calc(400 * var(--u));
  aspect-ratio: 575 / 400;
}

.tile--statuetablet img { width: 100%; height: 100%; object-fit: cover; }

.tile--visacardh1b {
  left: calc(608 * var(--u));
  top: calc(7738 * var(--u));
  width: calc(812 * var(--u));
  height: calc(575 * var(--u));
  aspect-ratio: 812 / 575;
  background: linear-gradient(140.646deg, #fff 4.3448%, var(--mist) 58.308%);
}

.tile--visacardh1b .tile__art--visacardh1b { width: 71.798%; }

.tile--visapricing {
  left: calc(20 * var(--u));
  top: calc(8323 * var(--u));
  width: calc(1400 * var(--u));
  height: calc(700 * var(--u));
  aspect-ratio: 2 / 1;
  background: var(--mist);
}

.tile--visapricing .tile__art--visapricing { width: 43.286%; }

.tile--endlockup {
  left: calc(598 * var(--u));
  top: calc(9043 * var(--u));
  width: calc(822 * var(--u));
  height: calc(560 * var(--u));
  aspect-ratio: 822 / 560;
  background: var(--paper);
  display: flex;
  align-items: center;
  justify-content: center;
}

.tile__clip { position: relative; display: block; width: 74.939%; aspect-ratio: 616 / 145.538; overflow: hidden; }
.tile__clip img { position: absolute; left: -1.1%; top: 0; width: 101.1%; height: 134.88%; max-width: none; }

/* --- Flag dimensions ----------------------------------------------------- */

.dimrow {
  left: calc(20 * var(--u));
  top: calc(1476 * var(--u));
  width: calc(929 * var(--u));
  height: calc(540 * var(--u));
  display: flex;
  gap: var(--gutter);
  align-items: flex-start;
}

.dim { position: relative; height: 100%; margin: 0; background: var(--ink); overflow: hidden; }
.dim--flag { flex: 0 0 auto; width: calc(460 * var(--u)); aspect-ratio: 460 / 540; }
.dim--original,
.dim--new { flex: 1 1 0; min-width: 0; aspect-ratio: 224.5 / 540; }

/* The frame nests the strip in a clipping window that trims its own top
   labels; both levels are kept, in percentages, so the crop holds at any size. */
.dim__crop { position: absolute; display: block; overflow: hidden; pointer-events: none; }
.dim__crop img { position: absolute; max-width: none; }

.dim--flag .dim__crop { left: -5%; top: -24.815%; width: 341.739%; height: 149.815%; }
.dim--original .dim__crop { left: -537.639%; top: -24.815%; width: 700.223%; height: 149.815%; }
.dim--new .dim__crop { left: -300.668%; top: -24.815%; width: 700.223%; height: 149.815%; }

.dim__crop img { left: -0.07%; top: 0; width: 100.13%; height: 100.27%; }

.dim__title { position: absolute; left: calc(20 * var(--u)); top: calc(20 * var(--u)); white-space: nowrap; }

.dim__spec {
  position: absolute;
  right: calc(20 * var(--u));
  bottom: calc(39 * var(--u));
  transform: translateY(100%);
  color: var(--spec);
  text-align: right;
  white-space: nowrap;
}

.dim--original .dim__spec,
.dim--new .dim__spec { right: calc(19.5 * var(--u)); }

/* --- Paired panes -------------------------------------------------------- */

.row { display: flex; gap: var(--gutter); align-items: flex-start; }

.row--lockups {
  left: calc(20 * var(--u));
  top: calc(2096 * var(--u));
  width: calc(1400 * var(--u));
  height: calc(700 * var(--u));
}

.row--collateral {
  left: calc(20 * var(--u));
  top: calc(5046 * var(--u));
  width: calc(1400 * var(--u));
  height: calc(700 * var(--u));
}

.pane { position: relative; flex: 1 1 0; min-width: 0; height: 100%; margin: 0; overflow: hidden; }
.pane--paper { background: var(--paper); }
.pane--ink { background: var(--ink); }
.pane__art { position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); height: auto; }
.pane__art--vertical { width: 27.487%; mix-blend-mode: difference; }
.pane__art--band { width: 38.058%; mix-blend-mode: screen; }
.pane__art--collateral { width: 53.669%; }
.pane__art--portrait { width: 53.669%; }

.band--lockups {
  left: calc(20 * var(--u));
  top: calc(2806 * var(--u));
  width: calc(1400 * var(--u));
  height: calc(700 * var(--u));
  display: flex;
  gap: calc(48 * var(--u));
  align-items: center;
  justify-content: center;
  background: var(--ink);
}

.band--lockups img { width: 27.245%; height: auto; }

/* --- Colour --------------------------------------------------------------- */

.row--palette {
  left: calc(608 * var(--u));
  top: calc(5846 * var(--u));
  width: calc(812 * var(--u));
  height: calc(610 * var(--u));
  overflow: hidden;
}

.pal { position: relative; flex: 1 1 0; min-width: 0; height: 100%; margin: 0; overflow: hidden; }
.pal--ink { background: #141414; }
.pal--paper { background: var(--paper); }
.pal--copper { height: 67.869%; }
.pal--statue { flex: 0 0 auto; width: calc(460 * var(--u)); }
.pal--img img { width: 100%; height: 100%; object-fit: cover; }

.swatches {
  left: calc(608 * var(--u));
  top: calc(6468 * var(--u));
  width: calc(812 * var(--u));
  height: calc(60 * var(--u));
  display: flex;
  gap: var(--gutter);
  align-items: center;
  justify-content: center;
}

.sw { flex: 1 1 0; min-width: 0; height: 100%; }
.sw--1 { background: #141414; }
.sw--2 { background: var(--paper); }
.sw--3 { background: linear-gradient(to bottom, #4d3222, #000); }
.sw--4 { background: linear-gradient(to bottom, #000, #054b5a); }
.sw--5,
.sw--6 { flex: 0 0 auto; width: calc(49 * var(--u)); }
.sw--5 { background: #d3b38f; }
.sw--6 { background: #7beafb; }

/* --- Patterns ------------------------------------------------------------ */

.row--patterns {
  left: calc(608 * var(--u));
  top: calc(7543 * var(--u));
  width: calc(812 * var(--u));
  height: calc(185 * var(--u));
}

.pat { position: relative; flex: 1 1 0; min-width: 0; height: 100%; margin: 0; overflow: hidden; }
.pat img { width: 100%; height: 100%; object-fit: cover; }
.pat--bottom img { object-position: bottom; }
.pat--crop .pat__clip { position: absolute; inset: 0; display: block; overflow: hidden; }
.pat--crop img { position: absolute; left: -133.82%; top: -11.25%; width: 244.8%; height: 236.83%; object-fit: fill; }

/* ==========================================================================
   Narrow screens — Figma node 6:2579.
   A different edit of the same story: mobile navigation, the ellis logotype
   as the title, and label/body rows stacked full width.
   ========================================================================== */

@media (max-width: 1023px) {
  :root {
    --pad: 10px;
    --u: 1px;
    --o: 0px;
    --gutter: 10px;
    --fs-label: 10px;
    --fs-body: 14px;

    /* The space above the mark, and so the height of the bar: this plus the
       thirty-pixel mark plus the twenty under it. The sheet behind the bar is
       measured from the same figure, so the two cannot drift. */
    /* The bar rides 12 higher than it did — 16 up and 4 of it back. The
       sheet's own top padding is worked out from this, so it moves with it. */
    --mbar-top: 18px;

    /* The height of whichever mark the bar is wearing — the ( P ) shut, the
       wordmark open — and so the rest of the bar's height. It was written
       into three places as a bare 30, which is three places to remember when
       it changes; the sheet's own top padding is one of them. A little
       smaller than the wide frame's, which keeps its own: at this size the
       wordmark was taking a quarter of the bar's width to say something the
       reader is looking at the page to find out. */
    --mbar-mark: 26px;

    /* The one gutter the drawer is on, and the one gap between its parts.

       Every primary thing in here stands on this line — the wordmark, the
       way out, the reel, the five links and the rules between them — and it
       is the page's own gutter, so the drawer lines up with what it is
       covering rather than a little inside it. Declared once; nothing in the
       drawer invents its own.

       The gap is the same between the header and the reel as between the
       reel and the list. It had been a hundred above the reel and sixty
       below it, which reads as a hole under the header rather than as a
       rhythm. */
    --mnav-gutter: var(--pad);
    --mnav-gap: 40px;

    /* How far down the narrow frame opens. The figure was 360 — the room the
       bar needed when its mark sat ninety pixels down the screen, and a fair
       share of an 812-tall phone spent before anything is read.

       What is left after the bar is the air between the nav and the first
       thing under it, so the two are read together: the bar is 80, which
       leaves 180. Home, Work and Post Scriptum all open on this, so it is
       one value rather than three that can drift apart. */
    --ntop: 260px;
  }

  /* The grey is the wide frame's mount board — the ground the comp is pinned
     to, visible either side of it. At this width the comp fills the screen,
     so there is no mount left to see except the strip the browser paints
     above the page from the root's own colour: a grey bar over the nav on
     iOS, where the status bar takes its tint from there. The root carries
     the page's own ground instead, so the bar is the page continuing.
     Post Scriptum sets its own further down and is more specific. */
  html,
  body { background: var(--paper); }

  html:has(.page--about),
  body:has(.page--about) { background: var(--cream); }

  html:has(.page--project),
  body:has(.page--project) { background: var(--bg); }

  .snav { display: none; }

  /* And the shut mark with it. It is drawn at body level rather than inside
     the nav — it has to be, to invert against the page — so hiding `.snav`
     never reached it, and `markShut` goes on marking the root from a nav that
     is no longer on screen. The drawer carries its own mark at this width, so
     what showed was two of them, one over the other. */
  .navmark { display: none; }

  /* --- Mobile nav (365:18978 shut, 365:18987 open) ---
     Shut, only the mark and the control take the pointer — the rest of the
     bar floats over the page. Open, a black sheet takes the whole viewport
     and the bar's two marks swap for their reversed pair. */
  /* Drawn white and differenced against the page, so the mark and the
     control come out as its inverse: black over white, white over black,
     and legible over a photograph — without every page naming a colour for
     the bar, and without it disappearing the moment a picture scrolls under
     it.

     The blend belongs here rather than on the bar inside. A fixed element
     makes a stacking context, so a bar blending inside this one can only
     see what is in here with it — which, shut, is nothing at all: it came
     out white, right on Post Scriptum by accident and invisible on every
     pale page. From out here the backdrop is the page.

     Off while the drawer is open, and while it is closing: then the sheet is
     the ground and the marks on it are meant to be read straight. */
  .mnav {
    display: block;
    position: fixed;
    left: 0;
    top: 0;
    z-index: 60;
    /* A fixed element's `100%` is the viewport including its scrollbar, so
       on a narrow desktop window the bar ran a scrollbar's width past the
       right edge and put the control out of reach. `--vw` is the width the
       page has; see the note on `--content`. */
    width: var(--vw, 100%);
    pointer-events: none;
    mix-blend-mode: difference;
  }

  .mnav.is-open,
  .mnav.is-shutting { mix-blend-mode: normal; }

  /* And off while the router is swapping pages, where the bar says its
     colour instead of working it out.

     A view transition photographs what it moves, and a blend cannot survive
     being photographed: an element with a `view-transition-name` is captured
     on its own, with no backdrop behind it to blend against, so `difference`
     resolves against nothing and the marks come out the raw white they are
     drawn in. That is the white glimpse before they turn black — and
     everything in this bar is named, the bar by itself and the three marks
     by the arrangement that moves the menu aside when a project's arrows
     arrive, so all of it flashes at once.

     What the blend would have produced is written here instead: dark marks
     over a light page, light over a dark one, decided from the ground the
     bar is standing on at each end of the swap. The ( P ) is drawn black and
     inverted to white for the blend, so on a light page it simply keeps the
     colour it was drawn in. See `navSwapDress` in app.js. */
  .nav-swapping .mnav { mix-blend-mode: normal; }
  .nav-swapping .mnav__bar { color: var(--nav-swap-ink, #000); }
  .nav-swapping .mnav__mark { filter: var(--nav-swap-mark, none); }

  .mnav__bar {
    position: relative;
    z-index: 2;
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin: 0 var(--mnav-gutter);
    /* The bar is fixed, so this is space above the mark and nothing else —
       the page beneath keeps its own. The inset is nothing in a browser tab,
       where the chrome already stands clear of the notch, and is the notch
       itself once the site is opened from the home screen or taken
       fullscreen. */
    padding: calc(var(--mbar-top) + env(safe-area-inset-top, 0px)) 0 20px;
    color: #fff;
  }

  .mnav__home,
  .mnav__toggle,
  .mnav__menu { pointer-events: auto; }

  .mnav__home { display: block; z-index: 3; }

  /* Shut it is the ( P ) mark, open it is the wordmark. The mark ships dark
     and the wordmark light; both are drawn white here, for the blend above
     to turn over against the page. */
  /* Height leads and width follows from the file's own proportions, so one
     figure sets both marks and neither can be squashed by a careless pair. */
  .mnav__mark { width: auto; height: var(--mbar-mark); filter: invert(1); }
  .mnav__wordmark { display: none; width: auto; height: var(--mbar-mark); }

  /* The drawer collapses back towards the top, so the strip of black behind
     the bar is the last of it to leave. Swapping the marks the moment the
     drawer was told to shut put the dark ( P ) and the dark rule on that
     black, where neither can be seen — which reads as the sheet sitting over
     the nav. The bar keeps its light dress until the collapse has finished;
     `is-shutting` is lifted in app.js when it has. */
  .mnav.is-open .mnav__mark,
  .mnav.is-shutting .mnav__mark { display: none; }

  .mnav.is-open .mnav__wordmark,
  .mnav.is-shutting .mnav__wordmark { display: block; }

  /* 896:52637 — the three marks on the right of the bar, each on a 24 box
     and 16 apart, with the menu between the two ways through the work. */
  .mnav__acts {
    display: flex;
    align-items: center;
    gap: 16px;
    pointer-events: auto;
  }

  /* Drawn as a mask rather than dropped in as a picture, so it takes the
     bar's own ink — which is light on Post Scriptum's black and dark on the
     rest, and is the same reason the menu's own two bars are painted in
     `currentColor`. */
  .mnav__step {
    display: block;
    width: 24px;
    height: 24px;
    color: inherit;
    background: currentColor;
    -webkit-mask: url("/assets/svg/nav-skip.svg") no-repeat center / 13px 7px;
    mask: url("/assets/svg/nav-skip.svg") no-repeat center / 13px 7px;
  }

  .mnav__step--prev { transform: rotate(180deg); }

  /* 896:52734 — Post Scriptum's two readings, on the bar. The same mark the
     wide panel carries, drawn out of the same arms, in this frame's pixels:
     a crossbar both of them have and an upright only the plus does. Painted
     in the bar's own ink, like everything else up here. */
  .mnav__zoom {
    position: relative;
    display: block;
    width: 24px;
    height: 24px;
    padding: 0;
    border: 0;
    background: none;
    color: inherit;
    cursor: pointer;
    opacity: 0.45;
    transition: opacity 180ms var(--ease-soft);
  }

  .mnav__zoom[aria-pressed="true"] { opacity: 1; cursor: default; }

  .mnav__zoom::before,
  .mnav__zoom::after { content: ""; position: absolute; background: currentColor; }

  .mnav__zoom::before { left: 5.5px; top: 11.5px; width: 13px; height: 1px; }
  .mnav__zoom--in::after { left: 11.5px; top: 5.5px; width: 1px; height: 13px; }

  /* In the menu there is nothing to resize: what is on the bar is the way
     out of it. */
  .mnav.is-open .mnav__zoom,
  .mnav.is-shutting .mnav__zoom { display: none; }

  /* The menu is the last mark in the row now and the arrows come up to the
     left of it, so arriving on a project moves nothing: the menu is where it
     was, and where it will be on the way out again. It had been between the
     two, which meant every arrival and departure slid it forty pixels.

     Still named, each its own thing in the router's snapshot rather than
     part of the bar's — that is what holds them still. Without the names the
     whole bar is one picture crossed with another, and a mark that is in the
     same place in both reads as a smudge rather than as a mark that did not
     move. */
  .mnav__toggle { view-transition-name: mnav-menu; }
  .mnav__step--prev { view-transition-name: mnav-prev; }
  .mnav__step:not(.mnav__step--prev) { view-transition-name: mnav-next; }

  .mnav__step[aria-disabled] { opacity: 0.3; pointer-events: none; }

  /* And none of the three travels. The menu has nowhere to go, and the two
     arrows were being dealt an entrance — six pixels of slide, every time,
     whether or not they had just been somewhere else. Pressing Next on a
     project goes to another project with the same three marks in the same
     three places, and they were sliding anyway: the entrance was written on
     `::view-transition-new`, which is drawn for a mark that has merely
     stayed put as surely as for one that has just arrived. A control under
     the reader's thumb should be where they left it. */
  ::view-transition-group(mnav-menu),
  ::view-transition-group(mnav-prev),
  ::view-transition-group(mnav-next) { animation: none; }

  /* In the menu there is nowhere to step to: what is on the bar then is the
     way out of it. */
  .mnav.is-open .mnav__step,
  .mnav.is-shutting .mnav__step { display: none; }

  .mnav__toggle {
    display: block;
    z-index: 3;
    padding: 0;
    border: 0;
    background: none;
    /* A button does not inherit its colour on its own — the browser gives it
       one of its own, which is what stood between the icon below and the
       bar's. */
    color: inherit;
    cursor: pointer;
  }

  /* Drawn in the bar's own colour rather than a named one. The bar is dark
     on the light pages and light on Post Scriptum's black, and the two bars
     of this icon are painted in currentColor — naming the ink here left it
     black on that black page, which reads as no icon at all. */
  .mnav__icon { --pico: 24px; color: inherit; }

  .mnav__close {
    display: none;
    font-family: var(--mono);
    font-size: 10px;
    line-height: var(--lh-mono);
    letter-spacing: 0.01em;
    text-transform: uppercase;
    color: var(--paper);
  }

  .mnav.is-open .mnav__icon,
  .mnav.is-shutting .mnav__icon { display: none; }

  .mnav.is-open .mnav__close,
  .mnav.is-shutting .mnav__close { display: block; }

  /* The overlay is the viewport, not a box measured against it.

     It used to be an absolute box inside the bar with a height animated from
     nothing to `100lvh`. Nothing was wrong with the arithmetic, but every
     part of it was a thing that could be wrong on a phone: a viewport unit
     that is not the visible viewport while the browser's bars are out, a
     height that has to be recomputed when they move, and a containing block
     one element away from the one that actually matters. Pinned to all four
     edges there is no arithmetic left to get wrong — `inset: 0` on a fixed
     box is the viewport by definition, at any orientation, with the bars out
     or away, and it needs no listener to keep it there.

     What collapses is the clip rather than the height, which is the same
     movement to watch — the drawer rolling up from the bottom — and leaves
     the box where it is while it happens. */
  .mnav__menu {
    position: fixed;
    inset: 0;
    z-index: 1;
    clip-path: inset(0 0 100% 0);
    overflow: hidden;
    /* Nothing behind this can be reached by a finger that overshoots it. */
    overscroll-behavior: none;
    /* The ground belongs to the drawer and not only to the sheet standing on
       it. They are the same size and the sheet alone was enough — until it
       is not: a sheet an iOS unit short of its box, a rounding at the foot
       of the screen, a frame mid-transition. Any of those is a seam of the
       page showing through the bottom of a takeover, and the fix costs a
       declaration on a box that is zero high until it opens. */
    background: var(--ink);
    transition: clip-path 420ms var(--ease);
  }

  /* Large viewport, not dynamic. A fixed element's containing block on iOS is
     the large viewport — the page as it stands with the browser's bars
     retracted — while 100dvh is whatever is visible right now. With the bars
     showing, the two disagree by exactly the height of the bars, and the
     takeover stopped that far short of the bottom. The sheet is a ground, so
     it can run under the bars; nothing in it sits down there. */
  /* Large viewport, not dynamic — and this is the one place on the page
     where that is the right way round.

     `dvh` is what is visible this instant, so a drawer measured in it would
     end exactly where Safari's toolbar begins and then shrink away from the
     bottom of the screen the moment the toolbar retracted, showing a band of
     the page under a takeover. `lvh` is the page with the bars gone: always
     at least the screen, sometimes a toolbar's worth more, and the surplus
     runs off the bottom where there is nothing to see. A takeover wants the
     larger of the two every time. What `dvh` is for is further in, where
     something has to stay in sight; see the sheet. */
  .mnav.is-open .mnav__menu { clip-path: inset(0); }

  /* The bar rides over the sheet, so the sheet starts below it. Its height is
     its own padding either side of the thirty-pixel mark, held here as one
     value so the two cannot drift — which is what left this at the old 140
     after the bar came up to the page's gutter, opening eighty pixels of
     black between the bar and the reel. */
  .mnav__sheet {
    position: relative;
    /* The overlay is already the viewport; this only has to fill it. */
    height: 100%;
    display: flex;
    flex-direction: column;
    gap: var(--mnav-gap);
    /* A short screen scrolls the drawer rather than cutting the last link
       off it, and the page behind stays where it is while that happens. */
    overflow-y: auto;
    overscroll-behavior: contain;
    /* The gutter is the page's own, which is the gutter the bar above is on.
       It had been 20 against the bar's 10, so the reel and the five links
       stood a hairline inside everything else on the frame — the one column
       on the page that did not line up with the rest of it. */
    /* Safe-area inset, the header row, one gap, the reel, the same gap, the
       list. The top is the bar's own height written out — its space above
       the mark, the mark, and the twenty under it — so the two cannot drift
       apart, plus the first gap: a flex `gap` falls between children and
       there is nothing above the first one for it to fall between. */
    padding:
      calc(
        var(--mbar-top) + env(safe-area-inset-top, 0px) + var(--mbar-mark) +
        20px + var(--mnav-gap)
      )
      max(var(--mnav-gutter), env(safe-area-inset-right, 0px))
      var(--mnav-foot)
      max(var(--mnav-gutter), env(safe-area-inset-left, 0px));
    background: var(--ink);
  }

  /* The ground runs to the bottom of the largest viewport on purpose; what
     stands on it has to stop at the bottom of the visible one, and the
     difference between the two is exactly the toolbar that is in the way.
     `100lvh - 100dvh` is nothing while the toolbar is retracted and the
     toolbar's own height while it is showing, so the foot of the sheet
     tracks it without anything being measured in script. Plus the home
     indicator, which is a separate claim on the same edge.

     Fallback first, for anything without the units: twenty, as before. */
  .mnav__sheet { --mnav-foot: 20px; }

  @supports (height: 100dvh) and (height: 100lvh) {
    .mnav__sheet {
      --mnav-foot: calc(
        max(20px, env(safe-area-inset-bottom, 0px)) + (100lvh - 100dvh)
      );
    }
  }

  /* No space of its own: the gap above it is the sheet's, and the same one
     that follows it. A hundred pixels of padding here was the hole under the
     header. */
  .mnav__preview { flex: 0 0 auto; }

  .mnav__preview img,
  .mnav__preview video {
    width: 100%;
    aspect-ratio: 206 / 112;
    object-fit: cover;
  }

  .mnav__list {
    flex: 0 0 auto;
    display: flex;
    flex-direction: column;
    gap: 20px;
    margin: 0;
    padding: 0;
    list-style: none;
  }

  /* The rule belongs between the links, not above the first. */
  .mnav__list li + li { border-top: 0.5px solid rgba(255, 255, 255, 0.2); padding-top: 20px; }

  /* Flush with the sheet's own gutter at both ends. There was ten pixels of
     padding on this side, which left the arrows standing thirty in from the
     edge while the words began twenty from the other — a row that does not
     line up with the picture above it or with itself. */
  .mnav__list a {
    display: flex;
    align-items: center;
    justify-content: space-between;
    font-family: var(--mono);
    font-size: 10px;
    line-height: var(--lh-mono);
    letter-spacing: 0.01em;
    text-transform: uppercase;
    color: var(--paper);
    text-decoration: none;
  }

  .mnav__list a:hover { text-decoration: underline; }

  /* The site's own mark, not a caret turned on its side. Every other way
     out of a row — a section name on the home page, More, an associate —
     draws this one, and a play triangle in the menu was the odd one.

     Drawn as a mask, so it takes the colour of the words beside it and comes
     up white on this sheet without a second file.

     The box is the drawing and no wider. It used to stand in a 12 square —
     the height is wanted, since a taller box would push this row's rules
     apart, but the extra width was not: the row is laid out `space-between`,
     so what it puts against the gutter is the box, and a mark floating in
     the middle of a box two and a half pixels wider than itself is a mark
     two and a half pixels short of the edge. The words on the other side
     begin at the gutter exactly, so the row came out lighter on the right
     than on the left by about the width of a hairline, down all five rows,
     against the rules that do reach. The height still stands clear of the
     drawing; only the width has been taken in to it. */
  .mnav__arrow {
    flex: 0 0 auto;
    width: 7.07px;
    height: 12px;
    background: currentColor;
    -webkit-mask: url("/assets/svg/section-arrow.svg") no-repeat center / 7.07px;
    mask: url("/assets/svg/section-arrow.svg") no-repeat center / 7.07px;
  }

  /* --- Page flow --- */
  .page {
    width: 100%;
    height: auto;
    overflow: visible;
    padding-bottom: 60px;
  }

  .b {
    position: static;
    inset: auto;
    width: auto;
    height: auto;
    margin: 0 var(--pad);
  }

  /* .b is static here, so these need to stay positioned or their absolute
     children escape to .page. */
  .tile,
  .pane,
  .dim,
  .pat,
  .pal,
  .hero { position: relative; }

  /* Media runs to the edges; copy keeps the 10px margin. */
  .hero,
  .tile,
  .row,
  .band,
  .dimrow,
  .swatches { margin-left: var(--pad); margin-right: var(--pad); }

  /* The case study sets its labels in the serif at body size rather than the
     mono at label size. Scoped to the page that wants it: unscoped, it was
     reaching every narrow page in the site and quietly turning the mono
     eyebrows the frames ask for — SCOPE, ABOUT, CREDITS, POST SCRIPTUM —
     into 14px serif. */
  .page--ellis .label {
    font-family: var(--serif);
    font-size: var(--fs-body);
    line-height: var(--lh-serif);
    letter-spacing: 0.01em;
    text-transform: none;
  }

  .body { letter-spacing: 0.02em; }

  .hero {
    margin: 0;
    aspect-ratio: 393 / 594;
  }

  .hero__statue {
    left: 50%;
    top: 50%;
    width: 96%;
    height: auto;
    aspect-ratio: 551 / 832;
    transform: translate(-50%, -50%);
  }

  .fold-sentinel { position: static; }

  /* Title becomes the logotype (6:2590); the text stays for assistive tech. */
  .blk--intro { border-top: 0; padding-top: 84px; }
  .blk--intro .blk__row { flex-direction: column; gap: 0; }

  .title { width: auto; height: auto; }

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

  .title__mark { display: block; width: 18%; min-width: 67px; height: auto; }

  .scope {
    flex-direction: row;
    justify-content: space-between;
    gap: var(--gutter);
    width: 100%;
    margin-top: 18px;
    padding-top: 20px;
    border-top: 0.5px solid var(--rule);
  }

  .scope .label { width: auto; }
  .scope .body { text-align: right; }

  /* Every copy block is the frame's label/body row. */
  .blk--about,
  .blk--mark,
  .blk--grid,
  .blk--colors,
  .blk--patterns,
  .blk--end,
  .blk--cards {
    display: block;
    padding: 20px 0;
    border-top: 0.5px solid var(--rule);
  }

  .blk--about .blk__row,
  .blk--cards .blk__row,
  .blk--grid .blk__row,
  .blk--colors .blk__row,
  .blk--patterns .blk__row,
  .blk--end .blk__row {
    display: flex;
    justify-content: space-between;
    gap: var(--gutter);
  }

  .blk--mark { display: flex; flex-direction: row; justify-content: space-between; gap: var(--gutter); }

  .blk--about .label,
  .blk--mark .label,
  .blk--grid .label,
  .blk--colors .label,
  .blk--patterns .label,
  .blk--end .label,
  .blk--cards .label { flex: 0 0 auto; width: auto; }

  .blk--about .body,
  .blk--mark .body,
  .blk--grid .body,
  .blk--colors .body,
  .blk--patterns .body,
  .blk--end .body,
  .blk--cards .body { flex: 0 0 auto; width: 73.9%; }

  .blk--approach { padding: 0 0 20px; }
  .blk--approach .body--wide { width: 73.9%; margin-left: auto; }

  /* Stacked media */
  .row,
  .band--lockups { flex-direction: column; height: auto; margin-top: var(--gutter); }
  .pane { flex: 0 0 auto; width: 100%; height: auto; aspect-ratio: 695 / 700; }
  .row--collateral .pane { aspect-ratio: 695 / 500; }
  .band--lockups { gap: var(--gutter); padding: var(--gutter) 0; }
  .band--lockups img { width: 60%; }

  .tile { margin-top: var(--gutter); }
  .tile--statuetablet,
  .tile--gridlayout,
  .tile--visacardh1b,
  .tile--endlockup { margin-top: var(--gutter); }

  /* The strip is shown whole here, not cropped into three windows. */
  .dimrow { display: block; height: auto; margin-top: var(--gutter); }
  .dim--original,
  .dim--new { display: none; }

  .dim--flag {
    width: 100%;
    aspect-ratio: 373 / 192.378;
    background: none;
  }

  .dim--flag .dim__crop { position: static; width: 100%; height: auto; }
  .dim--flag .dim__crop img { position: static; width: 100%; height: auto; }
  .dim__title,
  .dim__spec { display: none; }

  .swatches { height: 60px; margin-top: var(--gutter); }

  .row--palette { flex-direction: column; }
  .pal { width: 100%; height: auto; aspect-ratio: 3 / 2; }
  .pal--ink,
  .pal--paper { display: none; }
  .pal--statue { width: 100%; }

  .row--patterns { flex-direction: row; height: auto; }
  .pat { aspect-ratio: 195.5 / 185; height: auto; }

  .tile__stamp { display: none; }
  .tile__tl,
  .tile__tr { left: 10px; right: auto; top: 10px; }
  .tile__tr { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  /* At the source, not on the rules. A duration set here reaches only what
     no more specific rule has claimed, and the fold's own are more specific
     than this — so the fold went on taking its full time with the query
     doing nothing. The clock is a token now and this empties it. */
  :root {
    --snav-fold-ms: 1ms;
    --snav-open-ms: 1ms;
    --snav-order: 0ms;
    --snav-fill-ms: 1ms;
  }

  .mnav__menu,
  .snav__open,
  .snav__wordmark,
  .snav--collapsing .snav__panel,
  .snav--collapsing .snav__fold,
  .snav--collapsing .snav__g2 { transition-duration: 1ms; }

  .snav__logo,
  .snav__fold,
  .snav__item,
  .snav__main,
  .snav__ctrl-row {
    animation: none;
    transition-delay: 0ms;
  }
}

/* --- Copy arriving a line at a time ---------------------------------------
   Each word is given a mask of its own: the word sits a line lower than it
   belongs and the mask hides it, so sliding it up looks like type rising off
   the page rather than fading onto it.

   The delay is per line, not per word — every word that ends up on the same
   visual line shares one, so a paragraph arrives as a few long moves instead
   of a rattle of small ones. Which words share a line is something only the
   browser knows, so app.js reads it back after laying them out.

   Nothing here runs without script: unsplit copy is just copy.
------------------------------------------------------------------------- */

/* Copy that is going to arrive should not be seen arriving twice. It is held
   back until its words have been split and timed — which waits for the real
   typeface, so nothing reflows underneath it — and only while script is
   running, since without it there is nothing to reveal. */
.js [data-reveal]:not([data-reveal-split]) { visibility: hidden; }

.rv-word {
  display: inline-flex;
  overflow: hidden;
  /* The mask is the line box, and a descender would be cut off by it. */
  padding-bottom: 0.14em;
  margin-bottom: -0.14em;
  /* An inline-flex is an atomic inline, and an underline set on a link does
     not cross into one — so the moment a link's words were split for their
     reveal, its underline stopped being drawn. Taken from the link rather
     than left to reach it, which it cannot. Where nothing above has a rule
     this inherits none, and recomputes on hover with the link. */
  text-decoration: inherit;

  /* inset(top right bottom left): the side held back is the bottom, so what
     is open is the top and the edge between them travels down the word. The
     delay is written here, per line — see `timeLines` — which is where the
     travel now is as well. */
  clip-path: inset(0 0 100% 0);
  transition: clip-path var(--rv-text-ms) var(--ease-text) var(--d, 0ms);
}

/* The type does not move. It used to rise into a mask that stood still —
   which reads as words being lifted onto the page — and now the mask itself
   opens, downward, over type that is already where it belongs. Same
   direction the pictures take, and the same one the reader is travelling:
   see `[data-reveal-media]`, whose held-back side is the bottom too. */
.rv-word > i {
  display: block;
  font-style: inherit;
}

[data-reveal].is-revealed .rv-word { clip-path: inset(0); }

@media (prefers-reduced-motion: reduce) {
  .rv-word { clip-path: none; transition: none; }
}

/* The colon in the home page's stamp keeps time with it: half a second lit,
   half a second out. `step-end` rather than a fade, so it reads as a clock
   rather than as something breathing.

   It is its own element for this. The stamp is rewritten every second and
   the hour and the minute are written either side of the colon rather than
   through it, so nothing the clock does reaches this. */
}

@keyframes mstamp-tick {
  50% { opacity: 0; }
}

.mstamp__tick { animation: mstamp-tick 1s step-end infinite; }

@media (prefers-reduced-motion: reduce) {
  .mstamp__tick { animation: none; }
}

/* --- Rows arriving --------------------------------------------------------
   A row in the list already has a rule drawn across its top, so the rule is
   left where it is and the row's contents rise from behind it — the line
   stays put and the work appears under it, one after another.

   Each row waits for its own turn to be on screen rather than the list
   waiting for its top: a list taller than the window would otherwise have
   played its lower rows to nobody, and they would be sitting there already
   arrived by the time the reader got to them.
------------------------------------------------------------------------- */

/* clip-path rather than overflow, and measured rather than assumed. A row
   here is a box trimmed to the cap height of its type, so its letters draw
   outside it: `overflow: hidden` would cut the text in half, and 100% of that
   box is far too short a lift to hide it behind. app.js measures the ink and
   writes both values; the fallbacks are what a row whose box does contain its
   contents — the work list's, whose child is a 76px grid — would measure. */
/* Held back until app.js has measured it, the same way copy waiting to be
   split is. There is no lift the stylesheet can guess at that hides a row
   whose letters draw outside it, so rather than show it half covered and
   then correct it, the list waits the frame out. */
.js [data-reveal-rows]:not([data-reveal-fitted]) { visibility: hidden; }

/* The row's contents do not move either: the clip that holds them opens from
   its own top edge down, from a bottom that covers the row to the measured
   one that lets its ink through. Both ends are written by `fitRow`. */
.js [data-reveal-rows] > * {
  clip-path: var(--rv-shut, inset(0 0 100% 0));
  /* Both properties, in both places. A row of a list is very often a rule as
     well — `li + li` is where a list's rules live — so this rule and the one
     for rules below land on the same element with the same weight, and a
     `transition` shorthand names the whole property: whichever of the two
     the cascade happens to prefer, it silently drops what the other was
     animating. It did. Every row carrying a line lost its clip transition
     and snapped open the moment it was revealed, all of them at once and
     none of them in the order they had been dealt, while the first row of
     each list — the one with no line above it — arrived properly. */
  transition: clip-path var(--rv-text-ms) var(--ease-text) var(--d, 0ms),
    background-size var(--rv-rule-ms) var(--ease-text) var(--d, 0ms);
}

.js [data-reveal-rows] > .is-revealed { clip-path: var(--rv-clip, inset(0)); }

/* And the clip comes off once the row has finished arriving.

   It is the mask the row rises out of, and it is only wanted while the row
   is rising. Left on afterwards it goes on clipping everything the row ever
   draws — which cost nothing while a row drew only its own words, and cost
   the whole of the hover ground the moment one of them drew outside its box.
   The ground reaches into the gap below the row to meet the next rule; the
   resting clip lets six pixels past the row's edge, so the last sixteen of
   it were simply not painted. A box measures as though it is there, which is
   what made this hard to see: nothing in the geometry was wrong.

   `none`, not a wider inset. There is no figure here that is right — the
   ground is as tall as the air the list keeps, and the next thing to draw
   past a row will want its own.

   And on hover as well as when it is done, because the two are not the same
   moment. The row finishes arriving and the mask comes off a beat later; a
   pointer that reaches the row inside that beat was getting the ground at
   the mask's height and then watching it grow to its own when the mask went.
   A row under the pointer is a row the reader has arrived at, whatever the
   clock says. */
.js [data-reveal-rows] > .rv-open,
.js [data-reveal-rows] > .is-revealed:hover { clip-path: none; }

/* And without the description a shut row is holding. It is laid out at its
   full height inside a track of none, so it is in the box tree and in the
   range the row is measured with, but it is not on the page — measured, it
   put fifty-seven pixels of nothing under a nine pixel line and the row rose
   from under all of it. The clip is written against the row's own box, so it
   grows with the row when the description opens and needs no room kept. */
.rv-measuring .ab-row__more { display: none; }

/* And putting the measurement back means putting it there outright, not
   travelling to it — see `fitRows`. The mark rides the row rather than the
   document, so a row already on its way in is not pinned along with it. */
.js [data-reveal-rows] > .rv-setting { transition: none !important; }

@media (prefers-reduced-motion: reduce) {
  .js [data-reveal-rows] > * { clip-path: none; transition: none; }
}

/* Entered, not arrived — see `entersSettled`. Where a page says its first
   screen is simply there when it opens, the elements on it take their
   finished state outright and nothing travels to it. The mark rides the
   element, not the document: copy is flipped and released in one tick, but a
   picture holds the mark until its own file has arrived. */
.js [data-reveal].rv-settled .rv-word,
.js [data-reveal-rows] > .rv-settled,
.js [data-reveal-media].rv-settled,
.js [data-reveal-media].rv-settled > img,
.js [data-reveal-media].rv-settled > video { transition: none !important; }

/* --- The contact sheet — 845:51005, 846:51305 ------------------------------
   A sheet that comes in from the right over a dimmed page. It carries the
   page's own type — mono labels over serif answers — so it reads as part of
   the site rather than as a form bolted to it.

   The two names at the top are the title and the switch at once: an inquiry
   and an application are the same gesture to the same studio and differ only
   in what is worth asking, so the sheet changes what it asks rather than the
   reader having to go and find the other link.

   On the narrow frame there is no part of the measure to take, so it takes
   all of it.
--------------------------------------------------------------------------- */

.cpanel {
  position: fixed;
  inset: 0;
  /* Same trap as the narrow nav: fixed to the viewport means fixed to the
     viewport *and its scrollbar*, so the sheet hung a scrollbar's width past
     the right of the page and lost that much of its own gutter. See the note
     on `--content`. */
  width: var(--vw, auto);
  z-index: 200;
  /* Shut, it is not on the page at all: no pointer, no tab stop, nothing for
     a screen reader. `inert` on the element says the last two; this says the
     first, and keeps the sheet from catching clicks meant for the page. */
  pointer-events: none;
}

.cpanel.is-open { pointer-events: auto; }

.cpanel__scrim {
  position: absolute;
  inset: 0;
  background: var(--ink);
  opacity: 0;
  transition: opacity var(--cpanel-ms) var(--ease-glide);
}

.cpanel.is-open .cpanel__scrim { opacity: 0.2; }

/* Nothing in the sheet travels while the window is being resized.

   A shut sheet is not gone, it is parked off the edge — and which edge it is
   parked off changes at the fold, from the right on the wide frame to the
   bottom on the narrow one. With a transition on the transform, crossing
   that fold animates the one into the other, which walks the sheet across
   the screen in plain sight on its way from one hiding place to the other.
   The same goes for its height, which changes at the fold as well.

   A resized window is not a gesture, so there is nothing here to animate:
   the sheet is simply wherever the new window puts it. See `is-resizing` in
   app.js, which is on for as long as the resizing lasts and a breath after.
*/
.is-resizing .cpanel__scrim,
.is-resizing .cpanel__sheet { transition: none; }

.cpanel__sheet {
  position: absolute;
  inset: 0 0 0 auto;
  display: flex;
  flex-direction: column;
  width: min(calc(735 * var(--u)), 100%);
  /* 20 on three sides; the frame leaves a little more under the button than
     beside it — and more again where the home indicator is, now that the
     page runs under it. */
  padding:
    max(var(--pad), env(safe-area-inset-top, 0px))
    var(--pad)
    max(calc(32 * var(--u)), env(safe-area-inset-bottom, 0px))
    var(--pad);
  background: var(--paper);
  overflow-y: auto;
  overscroll-behavior: contain;
  /* Off the right edge rather than faded out: a sheet that arrives has come
     from somewhere, and where it came from is the side it is pinned to. */
  transform: translateX(100%);
  transition: transform var(--cpanel-ms) var(--ease-glide);
}

.cpanel.is-open .cpanel__sheet { transform: none; }

/* What the sheet is, for anything that cannot see the pair. Out of sight and
   still read: `display: none` would take it from the screen reader too. */
.cpanel__title {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.cpanel__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: calc(20 * var(--u));
  padding-top: calc(12 * var(--u));
}

.cpanel__pair {
  display: flex;
  align-items: center;
  gap: calc(12 * var(--u));
}

/* The name of the sheet you are not in is still a name, not a disabled
   control — it is dimmed rather than greyed, and it is the way across. */
.cpanel__tab {
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font: inherit;
  letter-spacing: 0.01em;
  cursor: pointer;
  opacity: 0.3;
  transition: opacity 180ms var(--ease-soft);
}

.cpanel__tab[aria-pressed="true"] { opacity: 1; cursor: default; }
.cpanel__tab:not([aria-pressed="true"]):hover { opacity: 0.6; }

/* Drawn, not typed — the same hairline the contact lines on the home page
   stand their pipe in. */
.cpanel__sep {
  width: 0.5px;
  height: calc(20 * var(--u));
  background: var(--rule);
}

.cpanel__shut {
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  cursor: pointer;
}

/* The frame leaves the top of the sheet empty and stands the form in the
   rest of it, with the message taking up whatever is over — so the button at
   the foot keeps its place whichever set of fields is in. */
.cpanel__form {
  flex: 1 1 auto;
  /* A flex item will not go under its own content unless it is told it may,
     and until it was told the form simply grew past the sheet and took the
     button with it — nothing inside ever felt the squeeze that was supposed
     to reach the gap at the top of it. */
  min-height: 0;
  display: flex;
  flex-direction: column;
}

/* The air the frame leaves under the names before the asking starts — and
   the first thing in the sheet to give way when there is not room for
   everything.

   It is a flex item rather than a margin so that it can. Every other figure
   in here is a share of the width, which grows with the window while the
   height to put it in does not, so a wide short screen has to find the
   difference somewhere; an empty top is the cheapest thing in the sheet to
   spend. It shrinks twenty times faster than the message does, so the gap is
   gone well before the box anybody is typing in starts closing, and it never
   grows past the frame's own 198 — slack goes to the message, which is what
   the frame does with it. */
.cpanel__form::before {
  content: "";
  flex: 0 20 calc(198 * var(--u));
  min-height: 0;
}

/* The fields themselves never give. What gives is the gap above them and,
   after that, the message — see `.cpanel__form::before`. */
.cpanel__grid {
  flex: 0 0 auto;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: calc(40 * var(--u)) calc(10 * var(--u));
  align-content: start;
}

/* The group the intent does not want is out of the layout and out of the
   submission both — see `data-cpanel-for` in app.js. `contents` so that its
   fields are laid out by the grid rather than by the group. */
.cpanel__only { display: contents; }
.cpanel__only[hidden] { display: none; }

.cpanel__field {
  display: flex;
  flex-direction: column;
  gap: calc(10 * var(--u));
}

.cpanel__field--wide { grid-column: 1 / -1; }

/* The frame gives the message 40 over it and the button 32 over that, which
   is why the air is written on the pieces rather than as one gap down the
   form. */
.cpanel__field--msg {
  flex: 1 1 auto;
  min-height: 0;
  margin-top: calc(40 * var(--u));
}

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

.cpanel__input {
  width: 100%;
  height: calc(40 * var(--u));
  padding: 0 calc(12 * var(--u));
  border: 0.5px solid var(--rule);
  border-radius: 0;
  background: none;
  font-family: var(--serif);
  font-size: var(--fs-body);
  line-height: var(--lh-serif);
  color: var(--ink);
}

.cpanel__input:focus {
  outline: none;
  border-color: var(--ink);
}

.cpanel__area {
  flex: 1 1 auto;
  height: auto;
  /* A floor, not a figure. The frame draws 260 on the inquiry and more on the
     application, which asks two rows fewer — so what it really says is that
     the message takes the room that is left.

     Flat, though, rather than a share of the width, which is the whole point
     of a floor here: everything else in the sheet grows with the window
     while the room to put it in does not, so the one part that is meant to
     give way has to stop growing at the same moment. It was 240 of the
     frame's units, which on a wide screen came to three hundred and spent
     the button off the bottom of the sheet. */
  min-height: 120px;
  padding: calc(12 * var(--u));
  resize: none;
}

/* Five boxes, and as many of them as the job actually is. Checked, a chip
   takes the ground the site gives anything else that is on. */
.cpanel__chips {
  display: flex;
  flex-wrap: wrap;
  gap: calc(9 * var(--u));
}

.cpanel__chip {
  display: flex;
  align-items: center;
  height: calc(40 * var(--u));
  padding: 0 calc(20 * var(--u));
  border: 0.5px solid var(--rule);
  cursor: pointer;
  transition: background-color 180ms var(--ease-soft), color 180ms var(--ease-soft);
}

/* The writing takes the chip's colour rather than the serif's own, so that
   turning the chip over turns what is standing in it — the same arrangement
   the list rows use, and for the same reason: `.h-body` sets black itself
   and would go on setting it over a black ground. */
.cpanel__chip > span { color: inherit; }

/* The box itself is the control; the input is only what holds the answer. */
.cpanel__chip input {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

.cpanel__chip:has(input:checked) {
  background: var(--hov-ground);
  color: var(--hov-ink);
  border-color: var(--hov-ground);
}

.cpanel__chip:has(input:focus-visible) { border-color: var(--ink); }

/* The way out of the form is the way out of everything else on this site: a
   line of the serif with the mark after it. */
.cpanel__go {
  align-self: flex-start;
  margin-top: calc(32 * var(--u));
  display: inline-flex;
  align-items: center;
  gap: calc(4 * var(--u));
  padding: 0;
  border: 0;
  background: none;
  color: var(--ink);
  font-family: var(--serif);
  font-size: var(--fs-body);
  line-height: var(--lh-serif);
  letter-spacing: 0.01em;
  cursor: pointer;
}

.cpanel__go:hover span:first-child { text-decoration: underline; text-underline-offset: 0.2em; }

.cpanel__note {
  margin: 0;
  font-family: var(--mono);
  font-size: var(--fs-label);
  line-height: var(--lh-mono);
  text-transform: uppercase;
  letter-spacing: 0.02em;
  color: var(--spec);
}

.cpanel__note:empty { display: none; }

@media (max-width: 1023px) {
  /* Up from the bottom, not in from the side — a thing that fills a phone
     comes up from the edge the thumb is nearest — and all of the way up, in
     one movement. It used to stop three quarters of the way and take the
     rest when the form was scrolled, which asked the reader to discover a
     second gesture before they could see what they had opened. The form fits
     the screen now, so there is no rest to take. */
  .cpanel__sheet {
    inset: auto 0 0 0;
    width: 100%;
    /* Always the full height, and held a quarter of it below the screen.
       Growing it instead meant animating a height while the reader was
       scrolling what was inside it — the box reflowing under the finger that
       was moving it, which is the whole of why it felt glitchy. A transform
       moves what is already laid out and nothing underneath it changes. */
    height: 100%;
    transform: translateY(100%);
    transition: transform var(--cpanel-ms) var(--ease-glide);
  }

  .cpanel.is-open .cpanel__sheet { transform: none; }

  /* Full, less whatever the keyboard has taken. The sheet keeps its bottom
     edge on the bottom of the screen and simply stops where the keys start,
     so the form is laid out in the part of the screen that is left and the
     field being filled in can be brought into it. A height is not in the
     transition — only the transform is — so this is an instant change and
     has nothing to animate against the keyboard's own arrival.

     Zero on the browsers that shorten the page for the keyboard themselves,
     which comes to the same thing. See `keyboardInset` in app.js. */
  .cpanel.is-open .cpanel__sheet.is-tall {
    transform: none;
    height: calc(100% - var(--cpanel-kb, 0px));
  }

  /* It is focused when it opens, and that is housekeeping rather than
     anything to show — the reader has just watched it come up. */
  .cpanel__sheet:focus { outline: none; }

  /* Nothing to scroll until it is full, and not then until it has got
     there. A quarter of the sheet is off the bottom of the screen, so
     scrolling it at the lower height would be scrolling content out of
     sight — and it is also what the gesture is for: at that height a swipe
     up means take the screen and can mean nothing else.

     The second half matters as much. A flick does not stop because the sheet
     has started moving: let it scroll the moment the rise begins and the
     rest of the same gesture scrolls the content while the sheet is still
     carrying it up, so the two add together and the reader lands well past
     the top of the form — the opening of it skipped, which is exactly what
     it looked like. It arrives first, and then it scrolls. `is-loose` is put
     on at the end of the rise; see app.js. */
  .cpanel__sheet { overflow: hidden; }
  .cpanel__sheet.is-loose { overflow-y: auto; }

  /* Quicker than the sheet's own arrival, since nothing can be scrolled
     until it is over. */
  .cpanel__sheet.is-tall { transition-duration: 320ms; }

  /* Two to a row, as on the wide frame: Name beside Email, Company beside
     Budget, and on Apply the Name beside the Position. A phone is narrow but
     these are short fields, and stacking them was spending a line of the
     screen on each — which is the whole of why the form did not fit.

     What still takes the row to itself is what needs it: the five chips,
     which wrap, and the portfolio address, which is long. */
  .cpanel__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px 10px; }

  /* The form is as tall as what is in it and the sheet scrolls, rather than
     the form stretching to the sheet and the message swallowing the
     difference. On the wide frame the message filling what is left is the
     frame's own arrangement; here it would be a box the length of a phone. */
  /* The frame holds the form a long way under the pair at the top of the
     sheet — the names and the way out are their own thing, and the asking
     starts well clear of them. Two hundred here, which is about what the
     wide frame leaves and reads as the same pause at this size. */
  /* The form takes the sheet rather than its own contents, so that the gap
     above it has something to give back when the screen is short — see the
     spacer below. */
  .cpanel__form { flex: 1 1 auto; min-height: 0; }

  /* The gap over the fields is the screen's, not the form's.

     It was asked for two thirds and given twenty parts of shrink, which fit
     both forms but put them in different places: an application asks two
     rows fewer than an inquiry, so its gap closed up less and its Name sat a
     long way below the inquiry's. Switching between the two moved the thing
     the reader was about to type into.

     A share of the screen instead, and no shrink: whatever the panel is
     asking, the first field is in the same place. Clamped, because a share
     of a short phone is not enough of a pause to read as one and a share of
     a tall one is a hole. */
  .cpanel__form::before { flex: 0 0 clamp(40px, 18svh, 160px); }


  /* The message's own box is what takes the slack, so the growth has to be
     here rather than on the textarea: `.cpanel__only` is `display: contents`
     and this is the form's own flex child, while the textarea is a child of
     this and can only ever have what this is given. */
  .cpanel__field--msg { flex: 1 1 auto; min-height: 0; margin-top: 24px; }

  .cpanel__field { gap: 8px; }
  .cpanel__input { height: 40px; padding: 0 10px; }

  /* After `.cpanel__input`, which a textarea also carries: the two weigh the
     same and the later one is the one that counts.

     It takes and gives where the gap above the fields no longer does —
     which is the wide frame's own arrangement, where an application's two
     spare rows go to the message rather than to the foot of the sheet. Two
     hundred as the asking, more of it on the panel that asks fewer
     questions, and down to 120 on a screen that cannot hold even that: so
     the fields start in the same place on both, the button sits on the
     bottom of both, and neither scrolls. */
  .cpanel__area { flex: 1 1 auto; min-height: 120px; padding: 10px; }

  .cpanel__note { margin-top: 16px; }
  .cpanel__chips { gap: 8px; }
  .cpanel__chip { height: 36px; padding: 0 14px; }
  .cpanel__sep { height: 18px; }

  /* The frame's own button on this side — the black bar the home page closes
     a list with and About closes Working Here with. The mark inside it is
     already hidden on this frame; see `.hsec__arrow`. */
  .cpanel__go {
    justify-content: center;
    align-self: stretch;
    width: 100%;
    height: 30px;
    margin-top: 29px;
    background: var(--ink);
    color: var(--paper);
    font-family: var(--mono);
    font-size: var(--fs-label);
    line-height: var(--lh-mono);
    letter-spacing: 0.02em;
    text-transform: uppercase;
  }

  .cpanel__go:hover span:first-child { text-decoration: none; }
}

@media (prefers-reduced-motion: reduce) {
  .cpanel__scrim,
  .cpanel__sheet { transition: none; }
}

/* --- Rules arriving --------------------------------------------------------
   A hairline draws itself from the left, in its turn, with the copy and the
   pictures around it — one run down the page, and a rule is one more thing
   in it. Which side it sits on and whether it is there at all are the
   stylesheet's business; app.js only finds the ones already drawn and marks
   them — see `prepareRules`.

   Drawn as a background rather than a box of its own. A pseudo-element would
   need somewhere to hang from, and giving `position: relative` to twenty-odd
   elements that do not have it is how an absolutely positioned child two
   levels down finds a new containing block and walks off across the page.
   A background changes nothing but what is painted: the border keeps its
   half pixel of the layout and gives up only its colour. Anything already
   carrying a background image is left alone — see the same function.
--------------------------------------------------------------------------- */

.js [data-reveal-rule] {
  background-image: linear-gradient(var(--rule), var(--rule));
  background-repeat: no-repeat;
  background-size: 0% var(--rule-w, 0.5px);
  /* Naming the clip as well, for the same reason the rule above names the
     background: the two meet on every row of every list, and whichever wins
     has to carry both. A rule that is not a row has no clip to animate and
     nothing happens. */
  transition: background-size var(--rv-rule-ms) var(--ease-text)
      var(--d, var(--rv-base, 0ms)),
    clip-path var(--rv-text-ms) var(--ease-text) var(--d, 0ms);
}

.js [data-reveal-rule="top"] {
  border-top-color: transparent;
  background-position: left top;
}

.js [data-reveal-rule="bottom"] {
  border-bottom-color: transparent;
  background-position: left bottom;
}

.js [data-reveal-rule].is-revealed { background-size: 100% var(--rule-w, 0.5px); }

/* Settled and measuring, the same as everything else. */
.js [data-reveal-rule].rv-settled { transition: none !important; }

@media (prefers-reduced-motion: reduce) {
  .js [data-reveal-rule] { transition: none; }
}

/* --- Pictures arriving ----------------------------------------------------
   The frame opens downwards from its top edge, the way the reader is already
   travelling, while the picture inside settles out of a slight zoom over a
   longer beat than the wipe. The two moving at different speeds is what
   stops it reading as a blind coming down: the picture is still coming to
   rest after the frame has finished.

   Nothing is shown until it has actually decoded, so a half-painted image
   never appears — which is the other half of why this looks calm.
------------------------------------------------------------------------- */

/* How far the frame wipes, how much the picture zooms, how long each takes,
   and whether it fades — all tokens, because one setting does not suit both
   a single hero the reader has stopped at and a grid of two dozen cards
   going past. `--media-step`, the gap between one picture and the next, is
   read off the frame by app.js. */
:root {
  --media-step: 90;
  --rm-wipe: 100%;
  /* A second was written for a hero the reader has stopped in front of, and
     every page but the project ones animates its first screen — so this is
     what the reader meets below the fold too, on top of whatever the run in
     front of it carried. A full second there is not composure, it is a wait:
     the picture is wholly on screen and still opening. Work and Post
     Scriptum had each already found their own answer to this; the rest of
     the site gets the same move on a shorter clock rather than a lesser one.
     Full throw, same zoom, same curve, a little under two thirds of the
     time. */
  --rm-wipe-ms: 620ms;
  --rm-zoom: 1.14;
  /* Outlasting the wipe is the point of it — see the note above. The
     overhang is a ratio, not a number, so it comes down with the wipe and
     the picture still settles after the frame has finished opening. */
  --rm-zoom-ms: 860ms;
  --rm-fade: 1;
  /* Not a fade: the gate that keeps a half-decoded picture off the page. It
     is lifted at the moment the file is ready, which is the same moment the
     mask starts to open, so giving it a duration set the picture fading up
     through its own mask — half of it on screen at six tenths of an opacity,
     dissolving out of the ground while the edge raced down it. That flicker
     is not the reveal; the mask is. Where the mask only travels part of the
     way, the fade does have work to do and those pages set their own. */
  --rm-fade-ms: 0ms;
}

/* The frame holds the picture's zoom in, whatever the page's own styles do —
   a frame without it would let the scale spill over its edges. */
.js [data-reveal-media] {
  /* inset(top right bottom left): the held-back side is the bottom, so what
     is open is the top and the edge between them travels down. */
  clip-path: inset(0 0 var(--rm-wipe));
  overflow: hidden;
  transition: clip-path var(--rm-wipe-ms) var(--ease-media) var(--d, 0ms);
}

.js [data-reveal-media] > img,
.js [data-reveal-media] > video {
  transform: scale(var(--rm-zoom));
  opacity: var(--rm-fade);
  transition:
    transform var(--rm-zoom-ms) var(--ease-media) var(--d, 0ms),
    opacity var(--rm-fade-ms) var(--ease-media) var(--d, 0ms);
}

.js [data-reveal-media]:not(.is-loaded) > img,
.js [data-reveal-media]:not(.is-loaded) > video { opacity: 0; }

.js [data-reveal-media].is-revealed { clip-path: inset(0); }

.js [data-reveal-media].is-revealed > img,
.js [data-reveal-media].is-revealed > video { transform: scale(1); opacity: 1; }

/* Work and Post Scriptum are both long runs of pictures arriving as the
   reader scrolls past — two dozen cards in one case, a scattered collage in
   the other. What suits a hero they have stopped in front of is too much
   here, so they are quicker, the zoom is barely there, and the pictures
   follow each other more closely.

   Quicker, but not different. Both pages used to open the frame from a third
   and let a fade carry the rest of the arrival, and a fade has no direction:
   beside copy rising into place, the pictures read as switching on while the
   words came up, which is the inconsistency. The wipe is what carries the
   arrival on every page now — the frame opens from nothing, upward, the same
   way the copy rises — and restraint is a matter of how it is taken rather
   than of swapping the movement for a dissolve.

   Which is a curve as much as a clock. The site's reveal easing is very front
   loaded: three quarters of the way there in the first fifth of the time. On
   a hero the reader has stopped in front of, that reads as confident. On a
   card that now travels the whole height of its frame it reads as a snap —
   the picture is open almost before it started, and a grid of them all doing
   it at once is a lot of page moving in a couple of hundred milliseconds.

   The answer is not to ease in instead. A curve that starts slowly holds the
   picture still while the reader is already looking at it, and a wait is what
   that reads as, however smooth the rest of it is. What the grids want is to
   leave at once and take their time arriving — a third of the way in the
   first tenth of a second rather than the hero's two thirds. Away promptly,
   in gently, and no hurry in the middle where the eye actually follows it.

   The other half of it is the tail. A hero's picture goes on settling out of
   its zoom after the frame has finished opening, and that overhang is what
   keeps the wipe from reading as a blind going up. It is worth having at a
   zoom of 1.14. At 1.03 there is nothing to see in it — but the clock still
   runs, and the grid was a second and a half resolving over a movement no
   one can make out. So here the picture settles with the frame rather than
   after it, and the fade finishes first of all, which leaves the card solid
   while the last of the frame opens instead of milky after it has. Same
   curve, same direction, a third less of it. */
/* The gap between one card and the next matters as much as how long a card
   takes, and the two are really one setting. A cascade only reads as a
   cascade when the gap is a decent fraction of the opening: at 38ms against
   460 every card on the screen was mid-move at the same moment as every
   other, so the run across the grid — which was being dealt correctly, left
   to right and row by row — could not be seen at all. Nearer a quarter and
   each card is visibly its own arrival, the eye follows the order across, and
   because each opening is shorter the whole screen still lands sooner than
   it did when they all went together. */
.page--work {
  --media-step: 85;
  /* A short opening, not a full one. The frame still opens downwards — the
     direction is the point and it is the same on every page — but on a hero
     the edge crosses the whole picture, and doing that on two dozen cards is
     a great deal of travel for something the reader is scrolling past. From a
     quarter, the movement still reads as a wipe and is over before it can
     start feeling like a performance. The picture is fading up underneath it
     either way, so nothing arrives abruptly for the shorter throw. */
  --rm-wipe: 26%;
  --rm-wipe-ms: 360ms;
  --rm-zoom: 1.02;
  --rm-zoom-ms: 360ms;
  --rm-fade-ms: 280ms;

  /* A card is a picture with its name under it, and the two arrive together
     or the card does not arrive at all. Same clock, same curve, same gap from
     one to the next as the pictures above them. */
  --rv-text-ms: 360ms;
  --ease-text: var(--ease-glide);
  --block-step: 85;
}

/* Post Scriptum keeps Work's restraint but is given a little more travel. Its
   pieces arrive one at a time rather than three across, so each has to carry
   the moment by itself, and the cascade runs between arrivals instead of
   within a row — which is why the gap between them is longer here. */
.page--ps {
  --media-step: 105;
  --rm-wipe: 36%;
  --rm-wipe-ms: 440ms;
  --rm-zoom: 1.03;
  --rm-zoom-ms: 440ms;
  --rm-fade-ms: 340ms;
  --rv-text-ms: 440ms;
  --ease-text: var(--ease-glide);
  --block-step: 105;
}

@media (prefers-reduced-motion: reduce) {
  .ab-row__more,
  .ab-row__desc,
  .ab-plus::after { transition: none; }

  .js [data-reveal-media] { clip-path: none; }
  .js [data-reveal-media] > img,
  .js [data-reveal-media] > video { transform: none; transition: none; }
}

/* ==========================================================================
   Home — Figma node 336:12136 (1440 x 2679)
   Same `--u` placement as the case study. Body copy here is set at 1.2
   leading, not the 1.5 the case study uses.
   ========================================================================== */

body { position: relative; }

.h-body {
  margin: 0;
  font-family: var(--serif);
  font-size: var(--fs-body);
  line-height: var(--lh-serif);
  letter-spacing: 0.01em;
  color: var(--ink);
}

.h-body a { color: inherit; text-decoration: none; }
.h-body a:hover { text-decoration: underline; }

/* A link set in running copy, ruled where it stands rather than only under
   the pointer. A link nobody can see is a link nobody clicks, and the three
   on the home page — the address and the two socials — were three things
   that behaved like links and did not look like them.

   743:43631 draws none of these ruled at rest — the hairline between a pair
   and the mark beside More are what mark the row as something to act on, and
   a rule under every value made the block read as four rows of links rather
   than four rows of facts. On hover it rules, so a pointer is answered.

   The offset is where that rule sits. The face puts a default underline close
   enough to the baseline to read as a strikethrough of the descender space;
   0.2em is what More is set on, so every link on the page rules at the same
   depth.

   `a.tlink` rather than `.tlink`, to match the weight of `.h-body a` above,
   which is what turned the rule off at rest — equal weight, and this comes
   later, so this is the one that counts. */
a.tlink { text-decoration: none; }
a.tlink:hover { text-decoration: underline; text-underline-offset: 0.2em; }

/* --- Site navigation (336:12243) -----------------------------------------
   The panel sits at the top of the column and the menu at its foot, 770
   apart. Two sticky groups carry that: the panel parks immediately, the menu
   rides up on scroll until it meets the panel, then both hold. Heights are
   the frame's own so the 392 between the groups stays exact whatever the
   text metrics do.

   The nav is laid out in `--snav-u`, its own copy of the comp pixel, which
   tracks the page's until the page would take the panel under 226 and then
   holds. So between 1024 and 1440 the nav keeps the frame's size while the
   rest of the page carries on scaling — its 20 margin included, so the
   gutter around the held panel is the frame's 20 rather than a scaled one.
   Whatever sits beside or beneath the nav measures in the same unit.
------------------------------------------------------------------------- */

.snav {
  position: absolute;
  left: calc(20 * var(--u));
  top: calc(20 * var(--u));
  z-index: 50;
  width: var(--snav-w);
  height: calc(100% - 40 * var(--u));
  pointer-events: none;
}

.snav__g1,
.snav__g2,
.snav__menu {
  display: flex;
  flex-direction: column;
  gap: var(--snav-gap);
  pointer-events: auto;
}

.snav__g1 {
  position: sticky;
  top: var(--pad);
  height: calc(218 * var(--snav-u));
}

/* The menu rests on the foot of the viewport, a gutter clear of it, and
   sticks 5 under the panel once the page has scrolled that far. The floor
   keeps it off the panel on a short window. */
.snav__g2 {
  position: sticky;
  top: calc(var(--pad) + 218 * var(--snav-u) + var(--snav-gap));
  /* Rests a gutter off the foot of the window: the panel, the five 45 rows
     and the gaps between them, taken off the window's height. */
  margin-top: max(
    calc(
      100dvh - 2 * var(--pad) - 218 * var(--snav-u) - 225 * var(--snav-u)
        - 4 * var(--snav-gap)
    ),
    var(--snav-gap)
  );
}

/* Home does not do that. Its nav never folds, so the menu riding up from the
   foot of the window to its resting place under the panel is the only thing
   the nav does on a scroll — a piece of movement with nothing behind it. It
   starts where it ends up. */
.page--home .snav__g2 { margin-top: var(--snav-gap); }

/* Positioned: opened out, the logo row rides over the picture. */
.snav__panel {
  position: relative;
  background: var(--nav-panel-on-light);
}

.snav__logo {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: calc(14 * var(--snav-u)) calc(8 * var(--snav-u));
  overflow: hidden;
  transition: padding-left 260ms var(--ease-soft);
}

/* The two marks share the slot and cross-fade in it, so neither moves. */
.snav__home {
  position: relative;
  display: block;
  width: calc(97.778 * var(--snav-u));
  height: calc(20 * var(--snav-u));
  color: inherit;
  text-decoration: none;
}

.snav__wordmark {
  position: absolute;
  top: 0;
  left: 0;
  display: block;
  height: calc(20 * var(--snav-u));
  transition: opacity 260ms var(--ease-soft);
}

.snav__wordmark { width: calc(97.778 * var(--snav-u)); }

/* The row keeps its 8 either side in both states (363:17730), so the mark
   sits on the same line as the wordmark rather than backing out of it. */
/* --- The shut mark ---------------------------------------------------------
   Drawn outside the nav, and that is the whole point. `.snav` carries a
   z-index, which makes it a stacking context, and blending is confined to the
   stacking context an element sits in — inside the nav the mark could only
   invert against the nav's own transparent panel, which is to say not at all.
   Out here its backdrop is the page.

   White through `difference` gives the inverse of whatever is behind: a dark
   mark on a pale project page, a light one on Post Scriptum's near-black, and
   it keeps up with a photograph passing under it mid-scroll rather than being
   decided once per page.

   Positioned from the same tokens the nav uses, so it lands exactly where the
   row it came from would have put it: the page's own gutter, and 14 down for
   the logo row's padding.
--------------------------------------------------------------------------- */
.navmark {
  position: fixed;
  left: var(--pad);
  top: calc(var(--pad) + 14 * var(--snav-u));
  width: calc(28.333 * var(--snav-u));
  height: calc(20 * var(--snav-u));
  z-index: 60;
  pointer-events: none;
  opacity: 0;

  background: #fff;
  mix-blend-mode: difference;

  -webkit-mask-image: url("/assets/svg/nav-ps-mark.svg");
  mask-image: url("/assets/svg/nav-ps-mark.svg");
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-size: contain;
  mask-size: contain;
  -webkit-mask-position: left top;
  mask-position: left top;

  /* In from where the logotype stands — the row's eight of inset — and out to
     the page's own gutter, the 20 everything else is measured from. It is the
     only thing moving through the swap now, which is what makes the movement
     read as this mark arriving rather than as the whole row sliding. */
  translate: calc(8 * var(--snav-u));

  /* And out at once, with no fade at all. This mark inverts whatever is
     behind it, and opening fills the panel back in underneath it over 360ms
     while the mark itself was fading over 260 — so for a quarter of a second
     it hung there half transparent with its own backdrop changing, flipping
     colour as the fill arrived. That is the flash. A blend cannot be faded
     across a backdrop that is moving too; the way out is not to be there for
     it. Arriving is a different matter — the panel is emptying on its own
     delayed clock then, and the fade belongs to the state being entered, so
     the rule below keeps it. */
  transition: none;
}

.nav-shut .navmark {
  translate: 0;
  transition:
    opacity 260ms var(--ease-soft),
    translate 260ms var(--ease-soft);
}

/* Shown on exactly the terms the mark inside the nav used to be: shut, not
   woken by the pointer, not holding focus, and not behind the reel. app.js
   works that out and marks the root — see `markShut` there for why it is not
   a `:has()` selector. */
.nav-shut .navmark { opacity: 1; }

/* Folding, it arrives on the same delayed clock as the wordmark it replaces —
   which is the stack's own, halved, the same as that wordmark reads. It had
   been left on a pair of numbers from an older clock and was landing a sixth
   of a second before the nav had finished, so the swap was over while the
   panel was still travelling. */
.nav-shut:has(.snav--collapsing.is-folding) .navmark {
  transition-delay: calc(var(--snav-fold-all) / 2);
  transition-duration: calc(var(--snav-fold-all) / 2);
}

@media (prefers-reduced-motion: reduce) {
  .navmark { transition-duration: 1ms; }
}

/* Shut, the nav is the mark alone on the page (421:30446). Bring the pointer
   to the corner and it becomes the wordmark on a filled panel with the
   control beside it (421:30076); the control then opens it in full
   (421:30217).

   The waking is `.is-engaged`, set from pointer movement in app.js rather
   than from `:hover`. A reader who clicks the control and then scrolls leaves
   the pointer sitting in the corner, and `:hover` would hold the panel open
   under it — it is the moving that asks for the nav, not where the pointer
   happened to be left.

   Written as an absence so that waking simply stops suppressing: the panel's
   colour then stays whatever the page gives it — white over the project
   pages, a tenth of black on the cream, a tenth of white on Post Scriptum. */
/* The logotype only fades. It used to travel as well — the row gave up its
   left inset as it shut, carrying everything in it eight to the left — and a
   wordmark sliding sideways while it dissolves is two things at once: the eye
   follows the movement instead of the cross-fade, which is what stopped the
   swap reading as one mark becoming the other. The travel belongs to the mark
   arriving, not to the one leaving. */
.snav--collapsing.is-collapsed:not(.is-engaged) .snav__g1:not(:focus-within) .snav__wordmark {
  opacity: 0;
}

/* --- Collapsing variant (371:21687 open, 371:21877 shut) ----------------
   Panel and menu stack at the top and fold to the mark and its control on
   the first scroll; the control reopens them, the close folds them back.
------------------------------------------------------------------------- */

/* The groups keep the base rule's sticky, so opening the nav below the fold
   brings it to the top of the window rather than back at the head of the
   page. The panel folds to its logo row and the menu to nothing, which
   leaves the row itself — mark and control — exactly where it was.

   The fold runs on a grid track rather than a height: 1fr to 0fr is the same
   pair of numbers at every width, so the groups keep their own heights while
   the window is being dragged instead of animating after it. The track list
   carries no `auto` either, so there is nothing in it a browser has to guess
   how to interpolate.

   What folds is the reel alone. The logo row is its sibling, sitting on the
   panel's top edge, so it is outside everything that animates. And both
   groups ride one sticky box rather than sticking at offsets of their own,
   so the menu keeps its 5 off the panel through the whole fold instead of
   hanging where the panel used to end. */
.snav__stack { display: contents; }

.snav--collapsing .snav__stack {
  position: sticky;
  top: var(--pad);
  display: flex;
  flex-direction: column;
  gap: var(--snav-gap);
}

/* The nav is the one thing that must not move from page to page, so it is
   the shared element the router carries across. Not `.snav` itself: that is
   the page-tall frame the sticky travels in, so its height is the length of
   whatever project you are on, and naming it made the router morph a short
   page's frame into a long one's — the two snapshots were scaled to that box,
   which slid the nav down the window and squashed it on the way.

   The panel and the menu are named separately rather than the stack around
   them, and on both variants. Naming the stack made the router morph one box
   and cross-fade everything inside it, so a panel that gained or lost its
   reel dissolved from one height into the other. Named apart, each box is
   morphed on its own: the panel grows or shrinks to its new height and the
   menu slides to wherever that leaves it. That is the nav condensing, which
   is what it is actually doing, rather than a picture of it being swapped for
   another picture.

   Naming both variants is what anchors the home page to the rest. Home lays
   its menu on the foot of the window and the others tuck it under the panel,
   so the two are not the same layout — but they are the same two boxes, and
   carrying them across means the menu travels to its new place instead of the
   whole nav being replaced. The panel is in the same spot on every page and
   so simply stays put. */
.snav__panel { view-transition-name: site-nav-panel; }
.snav__menu { view-transition-name: site-nav-menu; }

/* The two snapshots at their own size, pinned to the group's top left. The
   default stretches each to fill the group, so a panel that gains or loses
   its reel across the swap would stretch through the whole move rather than
   just growing. */
::view-transition-old(site-nav-panel),
::view-transition-new(site-nav-panel),
::view-transition-old(site-nav-menu),
::view-transition-new(site-nav-menu) {
  width: 100%;
  height: 100%;
  object-fit: none;
  object-position: top left;
}

/* And the clock the boxes move on, which until now was the browser's own: a
   quarter of a second, on a generic curve, whatever the distance.

   That is a reasonable default for a box growing a little. It is not one for
   the home page, where the menu rests on the foot of the window and every
   other page tucks it under the panel — some three hundred and seventy
   pixels apart. Crossing that in 250ms is the jolt: the nav is meant to be
   the thing the site moves around, and instead it was the fastest thing on
   the screen. Given room and a curve that settles, the same move reads as the
   menu travelling to its new place.

   The panel is on the same clock though it rarely moves — it is the same nav,
   and the two halves parting company would be worse than either. Written out
   rather than taken from the token above: this tree is the browser's, not the
   page's, and it is not worth its inheriting. */
/* Clipped to the box, which by default they are not.

   The snapshots are drawn at their own size, pinned top left — that is what
   stops a panel stretching as it changes height. But leaving a project with
   no reel for a page that has one, the panel grows from 96 to 218, and the
   incoming snapshot is the full 218 from the first frame: a hundred and
   twenty-two pixels of it hanging below a box that has not grown into them
   yet, painted straight over the menu still on its way down. The reel
   arriving underneath About, Work and Post Scriptum is that overhang.

   Clipped, the same snapshot is uncovered as the panel opens into it, which
   is the panel growing to reveal the reel rather than the reel being there
   all along waiting for the panel to catch up. */
::view-transition-group(site-nav-panel),
::view-transition-group(site-nav-menu) {
  overflow: clip;
  animation-duration: 560ms;
  animation-timing-function: cubic-bezier(0, 0, 0.58, 1);
}

/* The snapshots cross-fade on the same clock, so the contents of a box that
   is still moving are not already resolved inside it. */
::view-transition-old(site-nav-panel),
::view-transition-new(site-nav-panel),
::view-transition-old(site-nav-menu),
::view-transition-new(site-nav-menu) {
  animation-duration: 560ms;
  animation-timing-function: cubic-bezier(0, 0, 0.58, 1);
}

.snav--collapsing .snav__g1,
.snav--collapsing .snav__g2 {
  position: static;
  height: auto;
  margin-top: 0;
}

.snav--collapsing .snav__panel {
  clip-path: inset(0 0 0 0);
  transition:
    background-color var(--snav-open-ms) var(--ease-soft),
    clip-path var(--snav-open-ms) var(--ease-soft);
}

/* The fill leaves on the reel's clock, which is now the same clock as
   everything else folding — so this no longer has to wait for it. */

/* The mark takes over only once the nav has finished folding. Swapping it
   while the boxes are still closing puts two changes on screen at once and
   the logo looks like it jumped ahead of everything else. The boxes end at
   their delay plus their duration, and the swap starts there.

   Folding again is the only case that waits: opening, and waking under the
   pointer, both want the wordmark back at once. */
.snav--collapsing.is-folding .snav__wordmark,
.snav--collapsing.is-folding .snav__logo {
  /* Halfway into the boxes closing rather than after they have, so the logo
     changes as part of the fold instead of as a postscript to it, and lands
     just as the nav settles — which is the whole stack's clock, halved, so
     it follows whatever that is set to. */
  transition-delay: calc(var(--snav-fold-all) / 2);
  transition-duration: calc(var(--snav-fold-all) / 2);
}

/* One curve and one clock in both directions, and the drawer's own 420, so
   every nav on the site closes the way it opens. Closing used to keep the
   soft curve — over three quarters of its travel in a fifth of its time —
   which read as the nav being snatched away rather than folding, however
   long the duration said it was. */
.snav--collapsing .snav__fold {
  display: grid;
  grid-template-rows: 1fr;
  transition: grid-template-rows var(--snav-open-ms) var(--ease-glide);
}

/* The inset rides the reel, not the box that folds: with border-box sizing a
   padded box cannot shrink past its own padding. */
.snav--collapsing .snav__main { min-height: 0; padding: 0; }

/* A folding box is a drawer closing over what is in it, not a press.

   The boxes fold by taking their contents' height away from them, and a flex
   item gives that height up on request: at six tenths shut the reel stood 3
   pixels tall where it draws 112, and each menu row 16 where it draws 45 —
   so every label slid up inside its own row as the row flattened under it,
   which is the text moving around that nothing asked it to do. Told not to
   give it up, the rows keep their size and the box simply passes over them.
   The box can still close to nothing: `min-height: 0` and `overflow: hidden`
   are on the box, and they are what let it be smaller than what it holds. */
.snav--collapsing .snav__ctrl-row,
.snav--collapsing .snav__img,
.snav--collapsing .snav__item { flex-shrink: 0; }

.snav--collapsing .snav__img {
  margin: calc(48 * var(--snav-u)) calc(10 * var(--snav-u)) calc(10 * var(--snav-u));
}

/* That 48 is the gap under the logo row. On a project page the paging row is
   sitting in it already, so the reel stops making its own and the panel comes
   out at the frame's 218 (549:42039) rather than 266. */
.snav__ctrl-row + .snav__img { margin-top: 0; }

.snav--collapsing .snav__g2 {
  display: grid;
  grid-template-rows: 1fr;
  transition: grid-template-rows var(--snav-open-ms) var(--ease-glide);
}

/* Layers, and in an order. The panel is drawn over the menu, so where their
   edges meet as they travel the upper one covers the lower rather than the
   two of them meeting on a seam; and the menu waits a beat, so the fold runs
   down the column — panel, then menu — instead of the whole nav going at
   once. Both directions take the same beat, so opening runs down the column
   the same way.

   They are flex items of the stack, which paints a z-index without asking
   them to be positioned. */
.snav--collapsing .snav__g1 { z-index: 2; }

.snav--collapsing .snav__g2 {
  z-index: 1;
  transition-delay: var(--snav-order);
}

.snav--collapsing .snav__menu { min-height: 0; overflow: hidden; }

/* BottomControl (549:42161). It is the first thing in the Preview frame, so
   it takes the panel's own ground rather than carrying one — it is inside the
   panel now, not a box beneath it, and it folds away with the reel because it
   is in the same box. 48 tall, and its arrows sit flush to a 10 inset, which
   the padding gives them. */
.snav__ctrl-row {
  display: flex;
  align-self: stretch;
  align-items: center;
  justify-content: space-between;
  height: calc(48 * var(--snav-u));
  /* A flex item with a definite height cannot shrink below it on its own, and
     on a project with no reel the row is all the fold has — so that 48 became
     the floor the fold could not close past. */
  min-height: 0;
  padding: 0 calc(10 * var(--snav-u));
  overflow: hidden;
}

/* --- Post Scriptum's two readings (892:52393) ----------------------------
   The panel carries a minus and a plus where every other page carries the
   reel: plus is the page as it is composed, minus the archive of everything
   on it at one size. Drawn rather than set, out of the same 8.667 by 0.667
   arms on a 16 box the pluses on About are made of, so the three marks on
   the site are one mark.
------------------------------------------------------------------------- */

/* The row is the whole of the fold here, so it does not want the 48 of air
   the preview above it needs. */
.snav__main--bare { padding-top: 0; }

.snav__zoom {
  position: relative;
  width: calc(16 * var(--snav-u));
  height: calc(16 * var(--snav-u));
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  cursor: pointer;
  opacity: 0.45;
  transition: opacity 180ms var(--ease-soft);
}

/* The reading you are in is the one that is lit. */
.snav__zoom[aria-pressed="true"] { opacity: 1; cursor: default; }
.snav__zoom:not([aria-pressed="true"]):hover { opacity: 0.75; }

.snav__zoom::before,
.snav__zoom::after { content: ""; position: absolute; background: currentColor; }

/* The crossbar both of them have. */
.snav__zoom::before {
  left: calc(3.667 * var(--snav-u));
  top: calc(7.667 * var(--snav-u));
  width: calc(8.667 * var(--snav-u));
  height: calc(0.667 * var(--snav-u));
}

/* And the upright only the plus does. */
.snav__zoom--in::after {
  left: calc(7.667 * var(--snav-u));
  top: calc(3.667 * var(--snav-u));
  width: calc(0.667 * var(--snav-u));
  height: calc(8.667 * var(--snav-u));
}

.snav__step {
  display: flex;
  align-items: center;
  justify-content: center;
  width: calc(16 * var(--snav-u));
  height: calc(16 * var(--snav-u));
  /* Six lower than the middle of the row. Centred, the arrows sat high
     against the logotype above them — an arrowhead is all mass and no
     shoulder, so its optical centre is not its box's. Moved rather than
     re-centred, so the target moves with the mark and the two stay the same
     thing. In the nav's own unit, like everything else in it. */
  translate: 0 calc(6 * var(--snav-u));
}

.snav__step img { width: calc(12.8 * var(--snav-u)); height: calc(6.4 * var(--snav-u)); }
.snav__step--prev img { transform: rotate(180deg); }
.snav__step[aria-disabled] { opacity: 0.3; pointer-events: none; }

/* The ground masks the whole way up, and fades while it does it.

   The fold can only take the panel down to the logo row — 48 of it, holding
   the place the control is reached at — so the ground's edge stopped there
   and the rest was a fade standing still. Which is the join you can see: a
   mask that halts short of the top and a dissolve that takes over from it.

   So the panel carries a clip as well, and it finishes the travel: the edge
   goes on up from that 48 to nothing, on the same clock as the fill leaving,
   so there is one movement to watch rather than two taking turns. It waits
   until the reel is already behind the fold's own edge — a little past three
   fifths of the fold — and after that there is nothing under the ground but
   the row itself, which by then holds nothing you can see: the logotype has
   gone and the control is only shown to a reader who has tabbed to it. That
   reader is what the `:not()`s are for; for them the panel stays whole.

   Opening, both come back first and together: the ground, then the things
   standing on it. */
.snav--collapsing.is-collapsed:not(.is-engaged) .snav__g1:not(:focus-within) .snav__panel {
  background: transparent;
  clip-path: inset(0 0 100% 0);
  transition-duration: var(--snav-fill-ms);
  transition-delay: calc(var(--snav-fold-all) - var(--snav-fill-ms));
  /* Two curves on one window, and the pair is what stops the finish landing
     flat. The edge takes the fold's own — an even travel that eases out — so
     the last of it arrives rather than snapping; both had been on the soft
     curve, which puts three quarters of a move into its first fifth, so the
     ground was gone in forty milliseconds and the rest of the run was a wait.
     The colour nearly holds and then clears, so what ends the fold is the
     last of the ground fading under an edge that has almost stopped — and it
     is clear of it before the edge arrives, so the thing that finishes is a
     fade and not a pixel going out. */
  transition-timing-function: var(--ease-fill), var(--ease-drawer);
}

/* Closing stays on the soft curve, which is very front loaded — two thirds
   of the way in the first fifth of the time, and all but done by halfway.
   That is fine here and wrong the other way round, and the difference is
   what the eye is on. Folding, the boxes wait a beat while the rows go
   first, so nobody is watching the box when it moves. Opening, the boxes
   lead and there is nothing else on screen: the same curve then flings the
   panel open and spends the rest of its time creeping the last few percent,
   which is the expansion feeling unnatural. */
.snav--collapsing.is-collapsed .snav__fold,
.snav--collapsing.is-collapsed .snav__g2 {
  grid-template-rows: 0fr;
  transition-duration: var(--snav-fold-ms);
  transition-timing-function: var(--ease-drawer);
}

/* Folding runs up the column, not down it: the menu tucks away first and
   the panel follows it. Opening keeps the other order — the panel is the
   ground and comes back before the menu that stands under it — so the two
   directions are each read in the order the reader would stack them by hand.

   The beat is on the fold here and on the menu there, which is the same
   number said from either end. */
.snav--collapsing.is-collapsed .snav__g2 { transition-delay: 0ms; }
.snav--collapsing.is-collapsed .snav__fold { transition-delay: var(--snav-order); }

/* --- The stagger -------------------------------------------------------
   The nav is a column of boxes, and it reads better arriving one after
   another than all together. Opening, the sequence runs down the column;
   folding, back up it, so the last row to appear is the first to go. Both
   directions fall out of putting the delays on the two states: a transition
   takes its delay from the state it is going to.

   A box and what is inside it move on the same clock — same delay, same
   duration — so they finish together. Giving the box a longer one leaves it
   shrinking after its contents have gone, and an empty box still closing
   reads as the reel hanging around after everything else has left.
------------------------------------------------------------------------- */

/* Nothing inside a box travels: the boxes carry the movement and what is in
   them only fades, so the sequence reads as the nav closing rather than as
   its contents sliding about inside it. The fade is still needed — a box
   collapsing over something fully opaque clips it instead of letting it
   leave. */
.snav--collapsing .snav__main,
.snav--collapsing .snav__ctrl-row,
.snav--collapsing .snav__item {
  /* Opening, on the boxes' own clock — the same duration, the same curve and
     the same standing start as the fold and the menu.

     What made the expansion feel wrong was never the fade. It was the fade
     running to a different clock from the box: each row dealt its own delay,
     55 through 220, inside a box that takes 360, so a row could be half
     uncovered by the edge sweeping down it while it was forty percent faded
     in by itself. Two reveals on one thing, disagreeing. Taking the fade
     away instead left the reel simply there, full strength, while the boxes
     were still moving — which is the same fault the other way up. Matched to
     the box, there is only one movement to see. */
  transition: opacity var(--snav-open-ms) var(--ease-glide);
}

/* The row's highlight is a hover, not part of the fold, so it has to be named
   again here: the rule above sets `transition` whole, which drops every
   property it does not list — the highlight snapped on every page but the
   home one, which is the only nav that does not collapse. */
.snav--collapsing .snav__item {
  transition: opacity var(--snav-open-ms) var(--ease-glide),
    background-color 160ms var(--ease-soft);
}

/* Folding, nothing fades. The mask is what takes them, and it takes them all
   the way.

   A fade that finishes before the box does leaves the box travelling over
   something already invisible — and a mask you cannot see arrive is a mask
   that stopped. It was ending a tenth of a second early and that tenth was
   the whole complaint: the edge appeared to halt short of the bottom because
   there was nothing left for it to pass over. There is now. The rows and the
   reel hold full strength until their own box has closed over them, and the
   nought below is set at that moment with nothing to travel — by then it is
   behind a box of no height and there is nothing there to see change.

   Opening still fades them in with the box: arriving, the two together read
   as one movement, which is the thing this started from. */
.snav--collapsing.is-collapsed .snav__main,
.snav--collapsing.is-collapsed .snav__ctrl-row,
.snav--collapsing.is-collapsed .snav__item {
  opacity: 0;
  transition-duration: 0ms;
  /* The menu is the first layer away, so its rows wait its own travel out. */
  transition-delay: var(--snav-fold-ms);
  transition-timing-function: var(--ease-hold);
  /* Folding does fade them, and has to: the box does not move until they
     have gone, and a box closing over something fully opaque clips it
     instead of letting it leave. On the same clock and curve as opening, so
     the two directions are one gesture played each way. */
}

/* Inside the panel, which is the second layer away — so its contents wait
   for the whole stack, as the menu's rows wait for the menu. */
.snav--collapsing.is-collapsed .snav__main,
.snav--collapsing.is-collapsed .snav__ctrl-row {
  transition-delay: var(--snav-fold-all);
}

/* A row also carries its hover, and the three longhands above are read
   across the whole list — so the highlight is named again here rather than
   being dealt the fade's clock. */
.snav--collapsing.is-collapsed .snav__item {
  transition-duration: 0ms, 160ms;
  transition-delay: var(--snav-fold-ms), 0ms;
  transition-timing-function: var(--ease-hold), var(--ease-soft);
}

/* The boxes move as one — same delay, same duration — so the reel and the
   menu open and close together rather than one trailing the other. The
   stagger lives in the rows, which is where it reads. */
/* The menu is not in this list: it is the second layer and waits its beat,
   set where the layers are. Naming it here would take that back — same
   specificity, and this comes later. */
.snav--collapsing .snav__fold,
.snav--collapsing .snav__main { transition-delay: 0ms; }

/* Opening: no stagger to deal. The box opening down the column is the
   stagger, and it deals the rows in order for nothing — first, second,
   third. A delay on top of that is the second clock. */
.snav--collapsing .snav__ctrl-row { transition-delay: 0ms; }
.snav--collapsing .snav__item { transition-delay: var(--snav-order), 0ms; }

/* Folding, everything leaves from a standing start, which is how it all
   arrives. The rows used to go first and back up the column, 55 apart, with
   the boxes following 165 behind them — and that head start is what read as
   the fold being wrong. By the time a box was a third shut its rows were
   down to a fifth of their strength, and by two thirds they were gone: the
   nav emptied and then an empty box finished closing. Together, what is left
   of the contents and what is left of the box are the same number at every
   moment of it, which is exactly true of opening. */

/* On a cold load the same sequence plays as an entry, top to bottom.
   `backwards` holds each box at its start while it waits its turn.

   Only on a cold load. A page swap brings a fresh nav, and left to itself the
   animation would run again on every navigation — the nav blinking out and
   rebuilding itself while the page behind it changes, which reads as the nav
   reloading rather than as the page moving underneath it. The nav is meant to
   be the still thing the rest of the site moves around, so once there is a
   nav on screen to carry across, the router carries it: the boxes morph to
   their new size and nothing fades in. `nav-anchored` is set on the incoming
   document by app.js, so it is there before the swap is drawn. */
@keyframes snav-in {
  from { opacity: 0; }
}

:root:not(.nav-anchored) .snav__logo,
:root:not(.nav-anchored) .snav__fold,
:root:not(.nav-anchored) .snav__item {
  animation: snav-in 360ms var(--ease-soft) backwards;
}

.snav__fold { animation-delay: var(--snav-step); }
.snav__item { animation-delay: calc(3 * var(--snav-step)); }
.snav__item:nth-child(2) { animation-delay: calc(4 * var(--snav-step)); }
.snav__item:nth-child(3) { animation-delay: calc(5 * var(--snav-step)); }
.snav__item:nth-child(4) { animation-delay: calc(6 * var(--snav-step)); }
.snav__item:nth-child(5) { animation-delay: calc(7 * var(--snav-step)); }

/* The control holds its slot in the row whether it shows or not, so it has
   nothing to shift against when the panel folds. */
.snav__open {
  display: flex;
  align-items: center;
  width: calc(16 * var(--snav-u));
  height: calc(16 * var(--snav-u));
  padding: 0;
  border: 0;
  background: none;
  color: var(--ink);
  cursor: pointer;
  opacity: 0;
  pointer-events: none;
  transition: opacity 260ms var(--ease-soft);
}

.snav--collapsing.is-collapsed .snav__open {
  opacity: 1;
  pointer-events: auto;
  transition: opacity 260ms var(--ease-soft) 180ms;
}

/* A pointer never sees this: reaching for the nav opens the nav, so by the
   time the corner is woken it is no longer folded and there is nothing left
   to ask for. What the control is still for is a reader who cannot hover —
   tabbing to it, which `:focus-within` shows, and the touch case below. */
.snav--collapsing.is-collapsed .snav__g1:not(:focus-within) .snav__open {
  opacity: 0;
  pointer-events: none;
  transition: opacity 260ms var(--ease-soft);
}

/* A finger cannot hover. Where there is no pointer the control stays put,
   or the nav would be unreachable once the page has scrolled. */
@media (hover: none) {
  .snav--collapsing.is-collapsed .snav__g1:not(:focus-within) .snav__open {
    opacity: 1;
    pointer-events: auto;
  }
}

/* Icon 305:5602 — two bars on a square box, at 30%, 40% and 13.33% of it. */
.pico {
  position: relative;
  display: block;
  width: var(--pico);
  height: var(--pico);
}

.pico i {
  position: absolute;
  top: calc(var(--pico) * 0.3);
  width: calc(var(--pico) * 0.13333);
  height: calc(var(--pico) * 0.4);
  background: currentColor;
}

.pico i:first-child { left: calc(var(--pico) * 0.3); }
.pico i:last-child { left: calc(var(--pico) * 0.56667); }

.snav__open .pico { --pico: calc(16 * var(--snav-u)); }

/* About names its ground rather than tinting through to the cream, so the
   nav is a solid object on the page instead of a veil over it.

   The lit row is not named, which means it is black, which is what it is on
   Home, on Work and on the projects — see the note below. It had been paper
   here, and paper on a cream panel is a row that lightens rather than
   answers: the same gesture as everywhere else on the site, arriving as a
   much quieter thing on this one page. Only Post Scriptum keeps a pale one,
   and only because its panel is dark. */
.page--about {
  --nav-panel-on-light: #e9e2c3;
}

/* The project frames sit the nav in white on the page's own grey. The paging
   row is not listed: it is inside the panel and takes its ground.

   The lit row is black here as it is on Home and Work: these three are the
   same nav on the same light ground, and a pale grey among them would be the
   one row that does not answer. */
.page--project,
.page--ellis {
  --nav-panel-on-light: var(--paper);
  --nav-item-hover: var(--ink);
  --nav-item-hover-ink: var(--paper);
}

.snav__main {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  padding: calc(48 * var(--snav-u)) calc(10 * var(--snav-u)) calc(10 * var(--snav-u));
  overflow: hidden;
}

/* The preview is a control: it opens the reel out across the page. */
.snav__img {
  position: relative;
  display: block;
  width: calc(206 * var(--snav-u));
  height: calc(112 * var(--snav-u));
  padding: 0;
  border: 0;
  background: var(--ink);
  overflow: hidden;
  cursor: pointer;
}

.snav__img img,
.snav__img video { display: block; width: 100%; height: 100%; object-fit: cover; }

.snav__item {
  display: flex;
  align-items: center;
  height: calc(45 * var(--snav-u));
  padding: 0 calc(10 * var(--snav-u));
  background: var(--nav-panel-on-light);
  overflow: hidden;
  color: inherit;
  text-decoration: none;
  transition: background-color 160ms var(--ease-soft);
}

/* The whole row answers the pointer rather than the word inside it. With a
   line's width between the boxes, which row is lit is unmistakable. */
.snav__item:hover,
.snav__item:focus-visible { background: var(--nav-item-hover); }

/* The words turn over with the ground beneath them. Named from inside `.snav`
   so this outranks the pages that set their labels by hand — Post Scriptum
   draws its nav white, and a white word on a white row is no word at all. */
.snav .snav__item:hover,
.snav .snav__item:hover .label,
.snav .snav__item:focus-visible,
.snav .snav__item:focus-visible .label { color: var(--nav-item-hover-ink); }

/* The reel's own way of opening out is gone: the picture opens the site's
   lightbox now, which is the same picture on the same black with the same
   way out, and one implementation of it rather than two. What stood here was
   the panel growing to the size of the window, with a second close drawn in
   the logo row to put it back — see `Lightbox.astro`, and `.snav__img` in
   SiteNav for what opens it. */

/* --- Column labels ------------------------------------------------------
   Define, Office, Contact, Social and Studio are set in the mono here. The
   narrow edit keeps them in the serif, so it puts them back below.
------------------------------------------------------------------------- */

.dlabel {
  margin: 0;
  font-family: var(--mono);
  font-size: var(--fs-label);
  line-height: var(--lh-mono);
  letter-spacing: 0.01em;
  text-transform: uppercase;
  color: var(--ink);
  text-box: trim-both cap alphabetic;
}

/* --- Define (743:44691) -------------------------------------------------
   A rule across the whole measure now, not the right two thirds of it: 1164
   from 255, with the stamp standing in the first 343 of it, Define in the
   118 after that and the standfirst filling what is left.

   The stamp is placed over that first column rather than made a flex item
   in it. It has to stay where it is in the markup for the narrow frame,
   which shows it above this row rather than inside it, so the row holds its
   column open with padding and the stamp is laid into it.

   Measured from the nav rather than from the frame, as the list of work is.
   The nav stops shrinking at 226 while everything else goes on scaling with
   the window, so a left of 255 of the frame's own units walks in under it
   somewhere below 1440 and the row ends up behind the panel.
------------------------------------------------------------------------- */

.hrow--define {
  position: absolute;
  left: calc(20 * var(--u) + 235 * var(--snav-u));
  top: calc(20 * var(--u));
  display: flex;
  width: calc(1399 * var(--u) - 235 * var(--snav-u));
  /* The rule is measured from the nav; the words are not. What the panel
     forces off the frame is a left edge, and only the edge — the label and
     the standfirst stand in the frame's own column, on 608 and 726, beside
     Office and Studio below them. So the indent is the distance between the
     two: whatever the nav takes, the padding gives back, and the two columns
     hold their line at every width. 353 at 1440, where they are the same
     number, and it shrinks from there as the frame does. */
  /* The air a rule gives what it heads, which About and Post Scriptum give
     the row that opens them too — and which the stamp laid over this one is
     placed for; see `.mstamp`. */
  padding: var(--rule-gap) 0 0
    calc(588 * var(--u) - 235 * var(--snav-u));
  border-top: 0.5px solid var(--rule);
}

.hrow__label { flex: 0 0 calc(118 * var(--u)); }

.mstamp {
  position: absolute;
  left: calc(20 * var(--u) + 235 * var(--snav-u));
  /* The row it is laid over starts on the frame's 20 and gives its words the
     air under its rule; this is the same sum, so the stamp sits on their
     line at every width rather than on a figure of its own that was right at
     1440 and nowhere else. */
  top: calc(20 * var(--u) + var(--rule-gap) + 0.5px);
  /* It fills the gutter the row's indent leaves, less the 10 the frame keeps
     between it and Define — so it narrows with that gutter instead of running
     under the label once the nav stops shrinking. */
  width: calc(578 * var(--u) - 235 * var(--snav-u));
  margin: 0;
}

/* Copy that faces a label or a rule is trimmed to its capitals, so the
   frame's measurement to it is a measurement to the letters rather than to
   the leading above them. On a block of several paragraphs the trim is on
   the block, not the paragraphs: it takes the leading off the top of the
   first line and the bottom of the last and leaves the lines between them
   the distance apart they were set. */
.h-standfirst,
.intro__body,
.ab-info__copy,
.ab-copy__text,
.pov-about__body,
.sec-copy__body,
.psintro__body,
.contact__line { text-box: trim-both cap alphabetic; }

.h-standfirst {
  flex: 1 1 auto;
}

.h-standfirst p { margin: 0; }

/* --- Contact (371:19834) ------------------------------------------------
   Three 30-tall rows on 50 centres, each closed by its own rule.
------------------------------------------------------------------------- */

.contact { margin: 0; }

/* The label and the line beside it are set on one centre, the way an
   associates row is — a 10 mono against a 14 serif, with the serif's line
   setting the height and the label centred on it. Laid out as a row rather
   than as three things pinned to the left edge, which is what makes that
   centring something the box does rather than a number someone works out.

   The 30 is the padding and the line together, not a height: a height would
   centre the pair in the row and drop the serif 4.5 below where the frame
   puts it. This way the rule is still 30 down and the serif has not moved. */
/* 743:43631 — a rule over each row rather than under it, and Studio at the
   foot of the same stack, so the four read as one list with a line between
   every pair and one closing the top. The row's height is its contents now:
   what the rule has to be right about is the 20 under it, which is padding,
   rather than a height that had to be worked out from the type. */
.contact__row {
  position: absolute;
  left: calc(726 * var(--u));
  width: calc(694 * var(--u));
  display: flex;
  align-items: flex-start;
  padding-top: var(--row-pad);
  border-top: 0.5px solid var(--rule);
}

/* 252, and a 48 pitch that stops shrinking where the type does.

   The type here is 14 at every width this frame covers; the rhythm around it
   was still scaling, so by 1024 a row had closed to 34 with 14 over the line
   and 10 under it — against the phone's 52, 21 and 21. Held at 48 the small
   end of this frame keeps the 20 and 18 it has at 1440, which is the phone's
   spacing to within a hair, and at 1440 nothing moves at all.

   Studio takes the fourth of them — see `.intro`. */
.contact__row--1 { top: calc(252 * var(--u)); }
.contact__row--2 { top: calc(252 * var(--u) + var(--row-pitch)); }
.contact__row--3 { top: calc(252 * var(--u) + 2 * var(--row-pitch)); }

.contact dt,
.contact dd { margin: 0; white-space: nowrap; }
/* The frame's label column at 0, as a width rather than an offset. */
.contact dt { flex: 0 0 calc(117 * var(--u)); padding-top: var(--label-nudge); }

/* Every row is one line of its own now — `Brooklyn, NY / 11222`,
   `Instagram, Linkedin` — rather than a value in each of two columns at the
   frame's 117 and 236. The frame drew them as columns because it drew each
   value as its own field; read as sentences they want a separator and the
   rest of the measure, which is what this is. The label keeps its column;
   nothing else in the row is one any more. */
.contact__line { flex: 1 1 auto; min-width: 0; }

/* 793:47787 — a 20 hairline standing between two values, where a pipe set in
   the serif used to. Twelve clear either side of it, and centred on the line
   rather than on the baseline it sits against: half a cap above the baseline
   is the middle of the type, so that is where the middle of the rule goes.
   Baseline-aligned rather than `middle`, which hangs a box off the x-height
   and would leave it a pixel or two low. */
.contact__sep {
  display: inline-block;
  width: 0.5px;
  height: calc(20 * var(--u));
  margin: 0 calc(12 * var(--u)) calc(var(--serif-cap) / 2 - 10 * var(--u));
  background: var(--rule);
  vertical-align: baseline;
}

/* --- Studio (371:19830) -------------------------------------------------- */

/* The fourth row of the contact list, and laid out as one: the rule above
   it is the one that closes Social. It cannot be a row of the list itself —
   its copy is paragraphs and a link, not a line — so it is the same box
   written out rather than the same element. */
.intro {
  position: absolute;
  left: calc(726 * var(--u));
  top: var(--intro-top);
  width: calc(694 * var(--u));
  display: flex;
  align-items: flex-start;
  flex-wrap: wrap;
  padding-top: var(--row-pad);
  border-top: 0.5px solid var(--rule);
}

.intro__label {
  position: static;
  flex: 0 0 calc(117 * var(--u));
  padding-top: var(--label-nudge);
}

.intro__body {
  margin-left: 0;
  /* The measure stops shrinking with the type, taking up to the room the
     column has, so the copy doesn't wrap down onto the photograph. Above
     1440 it is the frame's 454 as before. */
  width: min(calc(577 * var(--u)), max(calc(454 * var(--u)), 454px));
}

/* Follows the copy rather than sitting on the frame's 138, which the copy
   grows past once the type stops shrinking with the page.

   793:47128 puts it on 543, which is one line's leading under the copy's last
   baseline — the white above a line, without the line — and then 20 more, so
   the link reads as its own thing rather than as the end of the paragraph.
   Measured off the type rather than taken as the frame's own number, since
   what it is spacing itself from is a line of type and the copy grows past
   543 once the type stops shrinking with the page. The 20 is flat, like the
   rest of this: the type here is 14 at every width the wide frame covers, so
   nothing around it shrinks either. */
.intro__more {
  /* Its own line under the copy, and inset past the label column with it.
     The measure less that inset, not the whole of it: a basis of 100% and a
     margin of 117 is a box 117 wider than the box it is in, which is the
     page scrolling sideways by exactly that. */
  flex: 0 0 calc(100% - 117 * var(--u));
  margin: calc(var(--lh-serif) * var(--fs-body) - var(--serif-cap) + 20px) 0 0
    calc(117 * var(--u));
}

/* The word and the mark on one centre, four apart — 793:47128. */
.intro__morelink {
  display: inline-flex;
  align-items: center;
  gap: calc(4 * var(--u));
}

/* No rule under it now: the mark beside it is what says it goes somewhere.
   Still ruled on hover, so a pointer is answered. */
.intro__more a { text-decoration: none; }
.intro__more a:hover { text-decoration: underline; text-underline-offset: 0.2em; }

/* --- Photography (371:20126) --------------------------------------------- */

/* The home page's opening is a box only on the narrow frame, where the
   column is in flow and something has to hold the screen. Up here everything
   in it is placed absolutely against the frame's coordinates — the stamp,
   Define, the contact rows and Studio — and this has nothing to add, so it
   is not in the layout at all, which also leaves their containing block
   where it was. */
.hopen { display: contents; }

.shot { position: absolute; margin: 0; overflow: hidden; }

.shot img,
.shot video { display: block; width: 100%; height: 100%; object-fit: cover; }

/* 743:43649 — out from the right two thirds to the whole measure. Same 16:9
   as before, so the crop is unchanged; it is only larger and higher.

   Off the nav like the row above it and the list below, and its height off
   its own width rather than off the frame, so the crop holds while the
   picture narrows.

   And it gives way to Studio above it. That block's type stops shrinking at
   14, so its height is the same 183 at every width on this frame, while the
   slot the frame leaves it shrinks with the page — somewhere below 1360 the
   block is taller than the slot and More lands on the picture. So the
   picture takes whichever is lower: the frame's own place, or as far down as
   Studio actually needs. At 1440 that is the frame's own. */
.page--home {
  /* The rhythm of the four-row list, and what stops it shrinking.

     Everything in those rows is 14 or 10 type, which stops changing at 14 on
     the way down and at 18.2 on the way up, while the frame around it went on
     scaling either side — so the rows closed on type that had not moved below
     1440, and opened away from it above. The air is `--list-air` now, which
     every list on the site is set on, and the pitch is what that air makes of
     the type: 21 over the cap, the rule, the cap, and 21 under it. 52 at 14
     type, 55 at 18.2, and the same on About. */
  --row-pad: var(--list-air);
  --row-pitch: calc(2 * var(--list-air) + 0.5px + var(--serif-cap));


  /* Studio's own height, rule to the foot of More: the air under the rule,
     the copy, the line and 20 to More, and More's own. 14 type at every width
     this frame covers, so it is a length rather than a proportion — and so
     the block does not shrink with the slot the frame leaves it.

     Which is why the picture is placed off the block rather than off the
     frame alone: 129 is the air the frame leaves under Studio at 1440, and
     the picture keeps it. The two arms meet exactly there — 396 and 316.83
     is 712.83 — so nothing jumps at the width they cross. Under 1440 the
     block wins and the picture comes down with it, where the frame's own
     place had closed to 25 by 1024; over it the frame wins again. */
  --intro-top: calc(252 * var(--u) + 3 * var(--row-pitch));
  --intro-h: calc(var(--row-pad) + 167.5px);
  --shot-air: 129.33px;
  /* Three claims on where the photograph starts, and it takes the lowest.

     The frame's own coordinate. Then as far down as Studio actually needs,
     which is the note above. And then the fold: on arrival the panel's reel
     is the one thing moving on this page, and the photograph was coming up
     beside it showing a poster — two still pictures in the first screen,
     each asking to be the one looked at, and the one that is actually a film
     losing because it is the smaller. A screen down, the reel has the
     opening to itself and the photograph is the first thing scrolling
     reaches, by which time it is running.

     `svh` and not `vh` or `lvh`: the fold on arrival is where the browser's
     bars leave it, which is the small viewport. `lvh` would be a guarantee
     for a reader who has since scrolled the bars away, bought with a bar's
     worth of empty page under the opening for everyone who has not. `100vh`
     stands behind it for anything that cannot answer.

     Less `--o`, because this one is measured from the screen and the other
     two are measured from the frame. The page sits at the nudge — the few
     pixels between the gutter's fixed 20 and the frame's own scaling 20 —
     so a photograph placed a round screen down the page lands that many
     pixels off a screen down the window. Seven of them at 1920, and seven
     pixels of a poster above the fold is still a poster above the fold.

     It is a floor and not a position, so a short window keeps the frame's
     own spacing rather than being given more: at 1440 by 600 the frame's
     713 is already below a 600 fold and this says nothing. */
  --shot-top: max(
    calc(712.83 * var(--u)),
    calc(var(--intro-top) + var(--intro-h) + var(--shot-air)),
    calc(100vh - var(--o))
  );

  @supports (height: 100svh) {
    --shot-top: max(
      calc(712.83 * var(--u)),
      calc(var(--intro-top) + var(--intro-h) + var(--shot-air)),
      calc(100svh - var(--o))
    );
  }
  --shot-h: calc((1399 * var(--u) - 235 * var(--snav-u)) * 0.56313);
  --work-top: max(
    calc(1409 * var(--u)),
    calc(var(--shot-top) + var(--shot-h) + 40 * var(--u))
  );
  /* 60 below the frame's 2259. The list of work is longer than the frame
     drew and grows as the page narrows, so the frame's own gap had closed to
     15 by 1025; this is room for it. Given in the frame's units so it reads
     as part of the page's own rhythm rather than as a patch on it. */
}

.shot--office {
  left: calc(20 * var(--u) + 235 * var(--snav-u));
  top: var(--shot-top);
  width: calc(1399 * var(--u) - 235 * var(--snav-u));
  aspect-ratio: 1165 / 656.052;
}

/* Place names sit 20 in from the picture's edges, on its centre line —
   743:43650, which is 319.57 down a picture that is now 656 tall. The 255.11
   this held was the centre of the 523.7 it used to be. */
.shot__names {
  position: absolute;
  left: calc(20 * var(--u));
  right: calc(20 * var(--u));
  top: calc(319.57 * var(--u));
  display: flex;
  justify-content: space-between;
}

.shot__tl,
.shot__tr { color: var(--paper); }

/* --- Work index (371:20130) ---------------------------------------------
   A rule above every project. Rows fall on an 89.5 pitch: the rule, 14,
   the 61-tall card, 14.
------------------------------------------------------------------------- */

/* 743:43652 — the list comes in off the measure and an eyebrow takes the
   room it leaves, the way every other list on the site is labelled. A rule
   over the whole of it, and 14 under that before the first card. */
/* The sections below the photograph are a column in flow — the first thing
   on this page that is. Where it starts is the frame's coordinate; how tall
   it is, is its own business. */
.hstack {
  margin: var(--work-top) 0 0 calc(20 * var(--u) + 235 * var(--snav-u));
  width: calc(1399 * var(--u) - 235 * var(--snav-u));
  display: flex;
  flex-direction: column;
  /* The frame's 1409, 1831 and 2253 are 80 between one section and the next
     once each has been given its own height. */
  gap: calc(80 * var(--u));
  /* And room under the last of them. The page ends where its content ends,
     which without this is the last thought's baseline — a page that stops
     dead rather than one that finishes. In the frame's units, so it is 200
     at 1440 and holds its proportion either side of that. */
  padding-bottom: calc(200 * var(--u));
}

/* --- The three lists (801:49178, 801:49214, 801:49248) -------------------
   Selected works, Post Scriptum and Thoughts are one shape three times: a
   rule over the whole of it, the section's name and its mark in the room off
   the measure, and the list in the frame's 694. A row is a still at 108,
   what the row is called filling what is left, and a mono column of 216 at
   the end — a project and its year, an exploration and what it is, a thought
   and who wrote it. All of it in the mono: two voices in a row this small
   read as two rows. Written once here rather than three times, since nothing
   differs but the words.
------------------------------------------------------------------------- */

.hsec {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  padding-top: var(--rule-gap);
  border-top: 0.5px solid var(--rule);
}

/* The name and the mark on one centre, in the 16 box the frame gives the
   mark — which is what the mono is centred against, not the other way
   round. It is a link: the mark points somewhere, and the section it heads
   is three of something there is more of. */
/* The mark's box is 16 and the name's capitals are 7, so centring the two on
   each other drops the name four and a half below the top of the group. The
   group is lifted by that much, which puts the name where every other line
   under a rule on this site is — and leaves the mark centred on it, the box
   reaching above the words with nothing drawn in it. */
.hsec__eye {
  flex: 0 0 auto;
  display: flex;
  align-items: center;
  gap: calc(10 * var(--u));
  margin-top: calc((0.7 * var(--fs-label) - 16 * var(--u)) / 2);
  color: inherit;
  text-decoration: none;
}

/* Drawn as a mask rather than dropped in as a picture, so it takes the
   colour of the words beside it and follows them onto a dark page. */
.hsec__arrow {
  flex: 0 0 auto;
  width: calc(16 * var(--u));
  height: calc(16 * var(--u));
  background: currentColor;
  -webkit-mask: url("/assets/svg/section-arrow.svg") no-repeat center /
    calc(7.07 * var(--u));
  mask: url("/assets/svg/section-arrow.svg") no-repeat center /
    calc(7.07 * var(--u));
}

/* The mark takes a mask of its own in the reveal — see `rv-mark` in app.js —
   and a mask is a box the size of what is in it, so the mark cannot be moved
   on hover without being cut off by it. The name rules instead, which is what
   every other link on this page does. */
.hsec__eye:hover .label { text-decoration: underline; text-underline-offset: 0.2em; }

/* Neither the mark's mask nor a word's gives up its width: these sit in a
   row that is laid out, not a line that is set. */
.hsec__eye > .rv-word,
.intro__morelink > .rv-word { flex: 0 0 auto; }

/* The frame's 694 of the 1164. It was 810; the room the list gives up is
   room the name beside it never needed. */
.hsec__list {
  flex: 0 0 calc(694 * var(--u));
  display: flex;
  flex-direction: column;
  gap: var(--rule-gap);
  margin: 0;
  padding: 0;
  list-style: none;
}

/* The section's own rule stands over the first row, so the row draws none. */
.hsec__list > .hrow:first-child {
  padding-top: 0;
  border-top: 0;
}

/* Under a list, and said so. `.hrow--define` at the top of this page carries
   `.hrow` too, and this rule sits later at the same weight — so the row that
   opens the page was taking the list's 14 in place of its own 20, and the
   stamp beside it, which is drawn for 20, stood 6 clear of the words it is
   supposed to be level with. */
.hsec__list > .hrow {
  padding-top: var(--rule-gap);
  border-top: 0.5px solid var(--rule);
}

.hrow__link {
  display: grid;
  /* minmax(0, 1fr) rather than 1fr: a track of `auto` minimum will not
     shrink under its longest word, and one long word in the middle pushes
     the column at the end off the measure. */
  grid-template-columns: calc(108 * var(--u)) minmax(0, 1fr) calc(216 * var(--u));
  gap: calc(10 * var(--u));
  align-items: start;
  color: inherit;
  text-decoration: none;
}

/* 108 across and whatever tall the picture is. The frame sets the width and
   lets each still keep its own proportion — a square stays square instead of
   being cropped to the 16:9 the first row happens to be — and the shape is
   read off the upload's own dimensions in the Sanity reference, so the row
   is the right height before the picture has loaded. */
.hrow__thumb {
  aspect-ratio: var(--ar, 16 / 9);
  background: var(--mist);
  overflow: hidden;
}

.hrow__thumb img,
.hrow__thumb video { display: block; width: 100%; height: 100%; object-fit: cover; }

/* Thoughts are squares, whatever was uploaded for them.

   The other two lists let each still keep its own proportion, which is right
   where the pictures are the work: a square stays square rather than being
   cropped to the shape the first row happens to be. These are three cards in
   a set and read as one, so a row that is shorter than the two under it
   reads as a mistake rather than as a wide picture. The width is the same
   108 all three columns have; only the height is settled, and `object-fit:
   cover` above fills it without stretching anything. */
.hsec--thoughts .hrow__thumb { aspect-ratio: 1 / 1; }

/* Both on the line the rule gives them, which is the top of the still beside
   them. The frame dropped the words 4 under that edge and the column at the
   end a further 2 — figures for a row of two voices, one of them a fourteen.
   Both are the mono now and both are trimmed to their capitals, so what they
   want is the line itself. */
.hrow__title { padding-top: 0; }

.hrow__key {
  padding-top: 0;
  text-align: right;
}

/* The ground a clickable row takes under the pointer — see `--hov-ground`.
   Drawn by the row rather than set on it: a background would stop at the
   measure, and the frame carries the ground past the words at both ends.

   Measured off the row's own box and nothing else. The row's top edge is its
   rule; its bottom edge plus the list's gap is the next row's rule. So the
   ground runs from the one to the other whatever stands between them, and
   the air above the words — the row's own padding — is the air below them.

   This used to be worked out from the height of a line of type instead, and
   that only holds where a line of type is the height the stylesheet thinks.
   `text-box: trim-both` is what makes it so here; without it a name is 27
   tall rather than 12, every row grows by the difference, and a ground cut
   to the old figure stopped 15 short of the rule under it. A box cannot
   disagree with itself.

   `::before` comes first in tree order, so the picture and the words — all
   positioned, all after it — are painted over it with no z-index between. */
/* How far under the row the next rule is — the gap this list keeps between
   its rows. Said outright rather than left to the fallback in the rule
   below, which is there for a list that forgets to. */
.hsec__list { --hov-drop: var(--rule-gap); }

.hsec__list > .hrow { position: relative; }

.hsec__list > .hrow::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: calc(-1 * var(--hov-drop, var(--rule-gap)));
  left: 0;
  right: var(--hov-run);
  background: var(--hov-ground);
  opacity: 0;
  pointer-events: none;
  transition: opacity 180ms var(--ease-soft);
}

/* The first row of a list has the section's rule over it rather than one of
   its own, and no padding under it — so its ground reaches up for it. */
.hsec__list > .hrow:first-child::before {
  top: calc(-1 * var(--hov-drop, var(--rule-gap)));
}

.hrow__thumb,
.hrow__title,
.hrow__key { position: relative; }

/* Only where there is a pointer that can hover. A finger cannot, and a
   browser without one answers `:hover` on the thing last tapped — so the
   ground would come up under a row as the reader left the page by it and
   still be there when they came back.

   The pointer and not the width: the narrow frame is also what a desktop
   window comes to when it is dragged in, and there the ground is wanted and
   works. What it has to stay off is anything being touched, at any size. */
@media (hover: hover) {
  .hsec__list > .hrow:hover::before { opacity: 1; }

  .hsec__list > .hrow:hover .hrow__link,
  .hsec__list > .hrow:hover .hrow__title,
  .hsec__list > .hrow:hover .hrow__key { color: var(--hov-ink); }

  /* The ground is the affordance now; an underline on top of it is a second
     one saying the same thing. */
  .hsec__list > .hrow:hover .hrow__title { text-decoration: none; }
}

.mbtn { display: none; }


/* --- The footer (917:53399) ----------------------------------------------
   1440 x 166 of white, ruled across the measure at the top, laid on the
   page's own six columns: the wordmark in the first, the founding date in
   the second, the office in the fifth, the ways in in the sixth. Three and
   four are empty — the name is at the left of the page and the detail at the
   right, with the width of the page between them.

   The frame's columns come back from Figma as percentages of 1440 — 16.67%
   plus 15, 66.67%, 83.33% less 5 — which is the frame saying it scales. So
   it is drawn as six columns of the measure rather than four offsets: at
   1440 that lands 225 columns on 20, 255, 490, 725, 960 and 1195, which is
   the frame to the pixel, and under and over it the grid gives and takes
   rather than overrunning the page.

   It is a child of the shell and not of a page, so its gutter is the screen's
   own `--pad` with none of the page's `--o` to undo — which is what the
   black band it replaces spent a negative margin on, four times over.

   One column drops to two below 1024: six columns of a 373 measure is 57 a
   column, and `37 Greenpoint Ave` is twice that. The frame's own rhythm is
   kept — the 32 under a label, the 4 between the ways in, the 16 the mark
   sits in — and only the columns are restacked.
------------------------------------------------------------------------- */

/* The page and the footer in one grid: a margin between grid items is the
   margin, where two margins meeting in flow would have collapsed into the
   larger of the two and left the 200 at the mercy of whatever a page ends
   with. Static, so everything absolute on a page still measures off the
   body as it did. */
.shell { display: grid; grid-template-columns: minmax(0, 1fr); }

/* The ground a page stands on, named so that the things outside the page can
   stand on the same one. The colours are the pages' own tokens and not a
   second set of literals; what is said here is only which page takes which,
   and `:has()` is how Post Scriptum already hands its ground to the document
   — see `--ps-ground`.

   Everything below the page reads these: the 200 before the footer is the
   page's own padding and takes its ground for nothing, and the footer takes
   `--ground` deliberately, so a white footer never lands under a dark page
   or a cream one. */
:root {
  --ground: var(--bg);
  --ground-ink: var(--ink);
  --ground-rule: rgba(0, 0, 0, 0.2);
}

html:has(.page--home),
html:has(.page--work) { --ground: var(--paper); }

html:has(.page--about) { --ground: var(--cream); }

html:has(.page--ps) {
  --ground: var(--ps-ground);
  --ground-ink: var(--paper);
  --ground-rule: rgba(255, 255, 255, 0.2);
}

/* The 200 between a page and the footer, spent on the page's side of the
   boundary and nowhere else.

   On the footer's side it was the right distance and the wrong colour: the
   gap fell outside the page, so it painted the body's grey, and Work and
   Home — which are white, and About, which is cream — closed on a grey band
   between the last card and the footer. Every page paints its own ground, so
   the gap belongs inside the page: white under white, cream under cream, and
   on a project and Post Scriptum the body's grey, which is what those two
   are anyway.

   Said once, from the shell, so one rule covers every page — and at two
   classes it outranks the six `.page--x` rules that set padding as a
   shorthand and would otherwise each zero it again. Padding rather than a
   margin, which also stops a last child's own margin escaping through the
   foot of a page that is not a block formatting context. */
.shell > .page { padding-bottom: var(--footer-content-gap); }

.sfoot {
  position: relative;
  /* The page sets `top: var(--o)` on itself so its comp coordinates land on
     the true gutter — a relative offset, so its visual foot is `--o` below
     where the flow leaves it. The footer takes the same offset, so the 200
     is 200 on the screen rather than 200 in the flow. */
  top: var(--o);
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  column-gap: calc(10 * var(--u));
  /* The frame's 166. The content comes to 164.8 of it, so the last 1.2 is
     the frame's own and not a measure anyone drew. */
  min-height: calc(166 * var(--u));
  padding: calc(20 * var(--u)) var(--pad) calc(40 * var(--u));
  background: var(--ground);
  color: var(--ground-ink);
}

/* Everything in the footer takes the footer's ink. `.label` and `.h-body`
   each name a colour of their own, and a bare anchor takes the browser's —
   which the wordmark, drawn out of `currentColor`, rendered as link blue.
   Said on the three that were arguing rather than hoped for. */
.sfoot .label,
.sfoot .h-body,
.sfoot a { color: inherit; }

/* 917:53421 — 1400 wide off a 20 gutter, which is the measure. Drawn rather
   than bordered: a border would run the width of the footer, and the footer
   runs the width of the screen. */
.sfoot__rule {
  position: absolute;
  left: var(--pad);
  right: var(--pad);
  top: 0;
  border-top: 0.5px solid var(--ground-rule);
}

.sfoot__logo {
  grid-column: 1;
  align-self: start;
  width: calc(97.7778 * var(--u));
  height: calc(20 * var(--u));
}

/* The wordmark is drawn out of the ink rather than fetched in two colours:
   it is one flat shape, so a mask of it filled with `currentColor` is the
   light copy and the dark copy both, and it cannot be the wrong one of the
   two on a page whose ground is not what the markup assumed. Same file the
   rest of the site uses. */
.sfoot__mark {
  display: block;
  width: 100%;
  height: 100%;
  background: currentColor;
  -webkit-mask: url("/assets/svg/playlist-studio-wordmark-2026.svg") no-repeat
    center / contain;
  mask: url("/assets/svg/playlist-studio-wordmark-2026.svg") no-repeat center /
    contain;
}

/* The logo's box opens at 20 and the labels' caps at 27, so the seven
   between them is said once, here and on the columns. `.label` is trimmed to
   its cap, so a padding above it puts the cap exactly where the frame does. */
.sfoot__est,
.sfoot__col { padding-top: calc(7 * var(--u)); }

.sfoot__est { grid-column: 2; align-self: start; }

.sfoot__col {
  align-self: start;
  display: flex;
  flex-direction: column;
  gap: calc(32 * var(--u));
}

.sfoot__col--office { grid-column: 5; }
.sfoot__col--contact { grid-column: 6; }

/* 1.4 here against the ways in at 1.2 — the address is three lines of one
   thing and wants the air between them; a list of links is three things and
   does not. Both are the frame's. */
.sfoot__addr { line-height: 1.4; }

.sfoot__ways {
  display: flex;
  flex-direction: column;
  gap: calc(4 * var(--u));
  margin: 0;
  padding: 0;
  list-style: none;
}

.sfoot__link {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: calc(10 * var(--u));
  line-height: 1.2;
  text-decoration: none;
}

/* The words rule, not the row: an underline under the whole link would run
   out to the mark and through the space the frame leaves between them. */
.sfoot__link:hover .sfoot__name {
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

/* The same mark the sections use, at the same 7.07 in the same 16 box — see
   `.hsec__arrow`. The seven tenths off centre is the frame's own. */
.sfoot__arrow {
  flex: 0 0 auto;
  width: calc(16 * var(--u));
  height: calc(16 * var(--u));
  background: currentColor;
  -webkit-mask: url("/assets/svg/section-arrow.svg") no-repeat
    calc(50% - 0.7 * var(--u)) center / calc(7.07 * var(--u));
  mask: url("/assets/svg/section-arrow.svg") no-repeat
    calc(50% - 0.7 * var(--u)) center / calc(7.07 * var(--u));
}

/* An outline rather than anything that takes room, so a link taking focus
   never moves the row it is in. */
.sfoot a:focus-visible {
  outline: 1px solid var(--ground-ink);
  outline-offset: calc(4 * var(--u));
}



/* --- The footer on narrow screens ---------------------------------------
   No narrow frame was drawn for it, so nothing here is invented beyond the
   restack: six columns of a 373 measure would be 57 apiece and the address
   is twice that, so the four things pair off — the wordmark beside the date,
   the office beside the ways in — and every measure inside a column is the
   frame's own, stated flat because `--u` is floored at 0.62 down here and
   would otherwise take the frame's 32 down to 20.
------------------------------------------------------------------------- */
@media (max-width: 1023px) {
  .sfoot {
    grid-template-columns: repeat(2, 1fr);
    column-gap: 10px;
    row-gap: 40px;
    min-height: 0;
    padding: 20px var(--pad) calc(30px + env(safe-area-inset-bottom, 0px));
  }

  .sfoot__logo { width: 97.7778px; height: 20px; }
  .sfoot__est,
  .sfoot__col { padding-top: 7px; }
  .sfoot__est { grid-column: 2; }
  .sfoot__col { gap: 32px; }
  .sfoot__col--office { grid-column: 1; }
  .sfoot__col--contact { grid-column: 2; }
  .sfoot__ways { gap: 4px; }
  .sfoot__link { gap: 10px; }

  .sfoot__arrow {
    width: 16px;
    height: 16px;
    -webkit-mask-size: 7.07px;
    mask-size: 7.07px;
    -webkit-mask-position: calc(50% - 0.7px) center;
    mask-position: calc(50% - 0.7px) center;
  }

  .sfoot a:focus-visible { outline-offset: 4px; }
}

/* --- Home on narrow screens — Figma node 365:18041 (393 x 2796) ---------
   The same sections as the wide frame, run as one column on a 10 gutter:
   the stamp, Define, the two contact rows, a square crop of the office,
   Studio with its button, the project list, a second button, then the band.
   Placement below is the frame's own, in flow rather than absolute.
------------------------------------------------------------------------- */

.m-only { display: none !important; }

@media (max-width: 1023px) {
  :root {
    /* The break before a section that starts a new part of the page, as
       against the 23 between one block and the next. Home gives it to the
       first list under Studio and About to Offering under Info, and it is
       one number because those two are the same moment on two pages: the
       opening has finished talking, the page starts listing. They were 50
       and 45, which is the ordinary gap with a little on top rather than a
       break — and at that size neither page read as having a second part. */
    --sec-air: 100px;
  }

  /* No scrollbar drawn on the narrow frame.

     On a phone this takes nothing back — the indicator there is an overlay
     that floats over the page and is gone a moment after the finger is. It
     is a desktop window dragged under 1024 that gains by it: a classic
     scrollbar is a real two dozen pixels of layout, taken off the page and
     given to a trough, and the page then sits that far off the right edge
     with the paper showing down the side. Which is the same thing a phone
     shows when it has been pushed sideways, and the reason the two were
     easy to mistake for one another.

     `--vw` already measured around it, so nothing moves by this — it is the
     trough itself that goes. */
  html { scrollbar-width: none; }
  html::-webkit-scrollbar { width: 0; height: 0; display: none; }

  /* And the boxes that scroll inside the page: the contact sheet, and the
     drawer while it is open. A bar down the inside edge of a takeover is
     the same intrusion at a smaller size. */
  .cpanel__sheet,
  .mnav__menu,
  .mnav__sheet { scrollbar-width: none; }

  .cpanel__sheet::-webkit-scrollbar,
  .mnav__menu::-webkit-scrollbar,
  .mnav__sheet::-webkit-scrollbar { width: 0; height: 0; display: none; }

  .m-only { display: block !important; }
  .d-only { display: none !important; }

  /* 801:49355 runs this frame in a different order from the wide one: the
     photograph comes straight off the Define row, and the address, the
     contact and Studio follow it. Everything above the work is absolute on
     the wide frame, so the markup can keep that frame's order and this one
     can say its own — and the reveal reads boxes rather than markup, so it
     still runs down the page as it is seen. */
  .page--home {
    height: auto;
    padding: 0;
    background: var(--paper);
    display: flex;
    flex-direction: column;
  }

  /* Everything that comes before the photograph, and at least a screen of it.

     The narrow frame used to put the photograph straight after Define and
     leave the address, the contact and Studio under it. It reads the wide
     frame's way now: all of the writing first, the picture after it. Which
     also means the opening is no longer two short blocks holding a screen
     open between them — it has four, and on most phones they fill it on
     their own.

     The floor stays, for the same reason as `--shot-top` on the wide frame
     and said the way a column in flow can say it: this is a box, the box is
     given the screen, and what follows starts at the bottom of it. No offset
     to keep true — the height is the viewport's own and the content's,
     whichever is larger, so a short screen or a long standfirst pushes the
     picture further down rather than cutting into it.

     `min-height`, so the opening is never shorter than what is in it; the
     bar is fixed over the page and the stamp already carries its clearance,
     which is inside this box and so inside the screen it fills. */
  .hopen {
    display: flex;
    flex-direction: column;
    order: 0;
    min-height: 100vh;
  }

  @supports (height: 100svh) {
    .hopen { min-height: 100svh; }
  }

  .shot--office { order: 1; }
  .hstack { order: 2; }

  .snav { display: none; }

  .mlabel { flex: 0 0 auto; }

  /* The narrow project pages set .label in the serif; these frames keep the
     mono they share with the wide ones. */
  .page--home .label,
  .page--about .label,
  .page--work .label {
    font-family: var(--mono);
    font-size: var(--fs-label);
    text-transform: uppercase;
  }

  /* --- Stamp (365:18055) — clears the nav, which floats over the page.
     Above the Define row here, not in it, which is why the wide frame lays
     it over that row rather than making it a part of it. --- */
  .mstamp {
    position: static;
    width: auto;
    height: var(--ntop);
    margin: 0;
    padding: calc(var(--ntop) - 30px) var(--pad) 0;
    text-align: right;
  }

  /* --- Define (365:18049) --- */
  .hrow--define {
    position: static;
    display: flex;
    justify-content: space-between;
    gap: var(--gutter);
    width: auto;
    min-height: 101px;
    margin: 0 var(--pad);
    padding: 20.75px 0 0;
    border-top: 0.5px solid var(--rule);
  }

  .hrow__label { flex: 0 0 auto; }

  .h-standfirst {
    flex: 0 0 auto;
    width: 73.9%;
    letter-spacing: 0.02em;
  }

  /* --- Contact (365:18046, 365:18052) --------------------------------
     The narrow frame was drawn without the office's clock or the Social
     row, and carried only what the frame had. Both are on it now: the same
     three columns as the wide one, the two values sharing what the label
     leaves so they line up down the block. */
  /* The ten this had was measured under the photograph, which it used to
     follow — 865 against the 855 the picture ended on — and which is now
     below it. Following the Define row instead, the air that belongs over a
     rule is the air every rule on the site stands in: `--list-air`, the same
     figure the rows inside this block put either side of their own.

     Its first row keeps its rule too. That was taken off for the same
     reason, a rule directly under the photograph reading as the picture's
     own edge drawn twice, and there is no picture over it any more — so
     Office arrives the way Contact and Social and Studio do. */
  .contact {
    display: flex;
    flex-direction: column;
    margin: var(--list-air) var(--pad) 0;
  }

  /* The 52 split into padding either side of the line, for the reason given
     on the wide frame: a height here would centre the pair in the row. */
  /* The air either side rather than a height and the air on top: the frame's
     52 was 20.75 over a cap and 21 under it, which is the same row the wide
     frame draws — so it is written the same way, and the two stay together
     when the figure changes. */
  .contact__row {
    position: static;
    display: flex;
    gap: 0;
    width: auto;
    padding: var(--list-air) 0;
    border-top: 0.5px solid var(--rule);
    border-bottom: 0;
  }

  .contact dt { flex: 0 0 26.3%; }
  .contact__line { flex: 1 1 0; min-width: 0; }

  /* --- Photograph (801:49356) — full bleed, 393 by 440, the place names on
     its centre line rather than the wide frame's own. Four under the Define
     row it now follows. --- */
  .shot--office {
    position: relative;
    inset: auto;
    width: 100%;
    height: auto;
    margin: 4px 0 0;
    aspect-ratio: 393 / 440;
  }

  .shot--office img,
  .shot--office video { width: 100%; height: 100%; object-fit: cover; }

  .shot__names {
    left: var(--pad);
    right: var(--pad);
    top: 50%;
    transform: translateY(-50%);
  }

  /* --- Studio (365:18056) --- */
  .intro {
    position: static;
    display: flex;
    flex-wrap: wrap;
    justify-content: space-between;
    gap: 29px 0;
    width: auto;
    /* Straight off the last contact row, as the four rows of the wide frame
       run one into the next. */
    margin: 0 var(--pad);
    padding: 20px 0;
    border-top: 0.5px solid var(--rule);
  }

  /* The label takes the same rail as every other narrow row's. The copy's
     margin is the wide frame's 117 and has to go with it: left in place it
     pushed the copy past the measure, the row wrapped, and Studio ended up
     sitting on a line of its own with its copy indented underneath. */
  .intro__label { position: static; flex: 0 0 26.3%; }

  /* 73.7 of the measure, said out loud rather than taken as what is left.
     A basis of nought leaves the line with room in it on paper, so More —
     which is meant to wrap under the copy — could be laid on the same line
     and the paragraph squeezed to nothing between the two. Whether it did
     came down to a rounding: it wrapped at 393 and did not at 600. With the
     rail and the copy declared as 26.3 and 73.7 the line is full, and a
     third item on it has nowhere to go but the next one. */
  .intro__body {
    position: static;
    left: auto;
    flex: 0 0 73.7%;
    min-width: 0;
    width: auto;
    margin-left: 0;
    letter-spacing: 0.02em;
  }

  /* A line of its own, under the copy and the width of it. */
  .intro__more {
    position: static;
    left: auto;
    /* The width of the copy it closes, hard against its right edge — the
       frame gives every one of these buttons the column, not the measure.

       A line of its own, and that is the basis rather than the width: a
       basis of 73.7 against a label of 26.3 comes to exactly the measure, so
       the three would sit on one line with the paragraph crushed to nothing
       between them — which is the same way it broke the last time. */
    flex: 0 0 100%;
    max-width: 73.7%;
    margin: 0 0 0 auto;
  }
  .intro__more a { text-decoration: none; }

  /* Nagiation/Button (I365:18060) */
  .intro__more .intro__morelink,
  .mbtn {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
    height: 30px;
    background: var(--ink);
    color: var(--paper);
    font-family: var(--mono);
    font-size: var(--fs-label);
    line-height: var(--lh-mono);
    letter-spacing: 0.02em;
    text-transform: uppercase;
    text-decoration: none;
  }

  /* --- The three lists (365:18200) — the same row on three columns: the
     still, what the row says, and the mono at the right. The wide frame
     leads with the mono, which it has 226 to do; on this measure that column
     would take a third of the row to hold four figures, so it goes to the
     edge and the words take the room.

     Each section is a row of this frame like any other: its rule runs the
     measure, the name stands in the label rail, and the list begins where
     Studio's copy and Office's address begin. --- */
  .hsec {
    display: flex;
    flex-wrap: wrap;
    /* 801:49372 sets the rail against the column, with no gutter between
       them — 26.3 and 73.7 of the measure, which is what every other row on
       this frame is measured to as well. */
    gap: 0;
    width: auto;
    margin: 50px var(--pad) 0;
    padding-top: 20.75px;
    border-top: 0.5px solid var(--rule);
  }

  /* The rail every label on this frame stands in, and 2 down — the same 2
     that puts the first row's words against the top of its still. The mark
     goes: it points at a page this frame reaches through the menu, and on
     this measure the rail is barely wider than the name it holds. */
  /* No mark on this frame, so the name is the group and it needs no lift. */
  .hsec__eye {
    flex: 0 0 26.3%;
    align-items: flex-start;
    margin-top: 0;
  }

  /* The mark goes, and so does the mask around it once script has run —
     a mask left behind is a flex item of no width with a gap after it, which
     would pull the word in the button off its centre.

     Said of these two places rather than of `.rv-mark`, which is every mask
     the reveal puts round a drawn thing on any page: unqualified it took the
     client marks on About with it and left that row empty on a phone. */
  .hsec__arrow,
  .hsec__eye > .rv-mark,
  .intro__morelink > .rv-mark { display: none; }

  .hsec__list { flex: 0 0 73.7%; min-width: 0; gap: 20px; }

  /* One to a section, the width of the list it closes and hard against its
     right edge — 801:49381. The 29 is the frame's own gap between the last
     row and the button. */
  .hsec > .mbtn {
    flex: 0 0 100%;
    max-width: 73.7%;
    margin: 29px 0 0 auto;
  }

  /* Off the foot of Studio. The 50 between one section and the next is the
     list rhythm and stays; this is the break into it. */
  .hstack > .hsec:first-child { margin-top: var(--sec-air); }

  .hsec__list { --hov-drop: 20px; }
  .hsec__list > .hrow { padding-top: 20px; }

  .hrow__link {
    grid-template-columns: 98px minmax(0, 1fr) 98px;
    column-gap: 10px;
    row-gap: 14px;
  }

  .hrow__thumb { grid-area: 1 / 1; }
  .hrow__title { grid-area: 1 / 2; }

  /* Hard against the right edge, where a date belongs on a list this narrow
     — it had sat at the left of a 98 column and read as a third thing
     floating in the middle of the row. */
  .hrow__key { grid-area: 1 / 3; text-align: right; }


  .hstack {
    margin: 0;
    width: auto;
    gap: 0;
    padding-bottom: 200px;
  }


}

/* ==========================================================================
   About — Figma node 371:21547 (1440 x 4220), narrow 365:17958 (393 x 3667)

   Unlike the other pages this one runs in flow rather than against absolute
   frame coordinates: its copy blocks sit in fixed slots in the frame, and
   with the type floored at 14 they outgrow those slots below 1440. The gaps
   below are the frame's own, so at 1440 every block still lands on its
   coordinate; under it the column gives way instead of overlapping.
   ========================================================================== */

.page--about {
  height: auto;
  padding-top: calc(20 * var(--u));
  background: var(--cream);
  /* See the note on `.page--home`: the hover ground runs off the right of
     the screen and this is what stops it there. */
  overflow-x: clip;
  overflow-y: visible;
}

.ab-col {
  display: flex;
  flex-direction: column;
  /* The left gutter rides the page's nudge to 20; the right one is measured
     off the page box, which ends on the viewport. */
  margin: 0 var(--pad) 0 calc(20 * var(--u));
}

.ab-eyebrow { flex: 0 0 auto; }

/* --- Intro: Info over the photograph (371:21548, 371:21569) ------------- */

/* The pair starts on the frame's 490, which is 470 into the content. */
/* 743:44316 — the page comes out from the right two thirds to the whole
   measure, as the home page has. 235 puts it at 255, where the nav ends —
   in the nav's units, not the frame's. The panel stops shrinking at 226
   while the page goes on scaling, so 235 of the frame's own walks in under
   it: at 1100 this row began at 198 and the nav ended at 246, which put the
   rule and the eyebrow below it behind the panel. The words do not follow
   it out; see the indent on .ab-info. */
.ab-rowset--intro { margin-left: calc(235 * var(--snav-u)); }

/* The stamp stands in the first 343 of this row, laid over it rather than a
   flex item in it — the narrow frame shows it above the row, so the markup
   has to serve both. Same arrangement as the home page's opening. */
.ab-info {
  display: flex;
  /* Whatever the nav takes off the row's left edge, the indent gives back,
     so Info and its copy hold the frame's 608 and 726 at every width —
     the column Offering, Credits and Associates stand in below, and the one
     the home page's Define row holds. 353 at 1440, where the two units are
     the same number. */
  padding: var(--rule-gap) 0 0
    calc(588 * var(--u) - 235 * var(--snav-u));
  border-top: 0.5px solid var(--rule);
}

.ab-info .ab-eyebrow { flex: 0 0 calc(118 * var(--u)); }
.ab-info__copy { flex: 1 1 auto; }
.ab-info__copy p { margin: 0; }

.ab-media__img img { width: 100%; height: 100%; object-fit: cover; }

/* --- Who: Offering beside Credits and Associates ------------------------ */

/* Offering used to stand in a column of its own beside the other two; the
   three are one stack now, each on the same 470 and 695 as the next. It
   follows Info directly: no hero on this frame, and no marks either. */
.ab-rowset--who {
  display: flex;
  flex-direction: column;
  gap: calc(72 * var(--u));
  /* 468 off the foot of Info at 176.5 — 764:45947, the frame with the
     marks taken out again, closes the whole of that distance itself. */
  margin: calc(291.5 * var(--u)) 0 0 calc(235 * var(--snav-u));
}

.ab-main { flex: 1 1 auto; min-width: 0; }

/* Offering, Credits, Associates and Benefits are headed rows like Info above
   them, so the air under their rule is the same air — it had stayed on the
   frame's own 20 while Info moved to the figure the site keeps. */
.ab-block {
  display: flex;
  padding-top: var(--rule-gap);
  border-top: 0.5px solid var(--rule);
}

/* The eyebrow column carries what the nav took off the left edge, so the
   lists stay on the frame's 705 while their rules start clear of the panel.
   470 at 1440. */
.ab-offering .ab-eyebrow,
.ab-credits .ab-eyebrow,
.ab-associates .ab-eyebrow {
  flex: 0 0 calc(705 * var(--u) - 235 * var(--snav-u));
}

/* --- Lists -------------------------------------------------------------- */

.ab-list {
  display: flex;
  flex: 1 1 auto;
  flex-direction: column;
  gap: var(--list-air);
  margin: 0;
  padding: 0;
  list-style: none;
}

/* The frame carries a rule between rows, a gap either side of it. The air
   above the words is named, not just set: the plus is measured down from the
   top of the row and has to start below it. Wherever a row's padding is
   written, `--row-air` is written with it. */
/* `--row-air` is the row's own copy of it, which the plus measures from —
   see `.ab-plus`. Kept apart from `--list-air` because the narrow frame sets
   the row's copy to nought and gives each list its air outright. */
.ab-list li + li {
  --row-air: var(--list-air);
  padding-top: var(--row-air);
  border-top: 0.5px solid var(--rule);
}

/* Offering, Credits, Associates and Benefits were 20, 16, 20 and 20 of the
   frame's units, the first two with half a line added to carry the plus.
   Four rhythms for one thing. They are the one air now, and the plus is
   centred by reading it — see `.ab-plus`, which measures from the same
   figure — so nothing has to be added to make the mark land right. */

/* Positioned so the plus has its own row to hang off. Only a row that opens
   carries a head, and the head is what used to provide that — so on a row
   without a description the plus took the page as its containing block and
   went to the middle of it, six of them stacked on the right-hand edge. The
   reveal measured the distance to them and lifted those rows a thousand
   pixels. */
.ab-row {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
}

/* The frame sets these rows on the cap box, which is what holds its rhythm.

   And the colour comes from the row rather than from the type's own rule, so
   that turning a hovered row over to the paper ink is one declaration on the
   row instead of one for every piece of writing that might be standing in
   it. Both inherit black from the page, which is what they set themselves. */
.ab-row__name,
.ab-row__role {
  text-box: trim-both cap alphabetic;
  color: inherit;
}

/* Painted over the ground, which is the row's own `::before` and so comes
   first in tree order — these only have to be positioned to be after it. The
   head of a folding row already is; so is the plus, and the arrow. */
.ab-row__details,
.ab-row__name { position: relative; }

/* 743:44347 — a credit's role stands at 235 of the row rather than straight
   after the name, so the name takes that column and the pair sit against it
   rather than a gap apart. */
.ab-credits .ab-row__details { gap: 0; }
.ab-credits .ab-row__name { flex: 0 0 calc(235 * var(--u)); }

.ab-row__details {
  display: flex;
  align-items: flex-end;
  gap: calc(20 * var(--u));
}

/* --- A row that opens (Offering, Credits) -------------------------------
   The frame draws a plus beside these two lists. Where a row has been given
   a description in the Studio, that plus is a control: the row keeps its own
   line and the description unfolds beneath it. Where it has not, the row is
   exactly what it was, and so is the plus.
------------------------------------------------------------------------- */

/* The row itself stops being the flex line and becomes the stack: its line
   above, what opens below. */
.ab-row--fold { display: block; }

.ab-row__head {
  /* The head is the line, not the row: the rule and its air are above it,
     outside. So the plus inside one measures from here with nothing to clear.
   */
  --row-air: 0px;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}

/* Opening a row by its height would need a height to open to. A grid row
   going from none of its content to all of it needs neither, so the
   description can be a line or a paragraph and the row still travels. */
.ab-row__more {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 480ms var(--ease-soft);
}

.ab-row.is-open .ab-row__more { grid-template-rows: 1fr; }

/* The child of a 0fr row has to be told it may be nothing; a flex or grid
   item's automatic minimum is its content, which would hold the row open.

   845:50790 stands a portrait to the left of the description, so this is the
   row that holds the pair: the picture in the eyebrow's own 118, the words at
   418, and the frame's 117 between them.

   The air stays on the children as a margin rather than moving here as a
   padding. Everything in here sits in a grid track the row drives from 0fr to
   1fr, and the item is stretched to it: a margin is outside that box and gets
   clipped with the rest of the content, while a padding is inside it and
   cannot shrink — border-box or not, padding is never compressible, and 16
   over 20 held every shut row 36 open. */
.ab-row__moreinner {
  min-height: 0;
  overflow: hidden;
  display: flex;
  align-items: flex-start;
  gap: calc(117 * var(--u));
}

/* 118 by 158, which is the shape the picture was cropped to on the way out
   of Sanity — see `imgRatio` in about.astro. Stated here as well so the row
   holds its height before the file has arrived and nothing jumps. */
.ab-row__portrait {
  flex: 0 0 calc(118 * var(--u));
  aspect-ratio: 118 / 158;
  overflow: hidden;
  margin: calc(16 * var(--u)) 0 calc(20 * var(--u));
  opacity: 0;
  transition: opacity 260ms var(--ease-soft);
}

.ab-row__portrait img { display: block; width: 100%; height: 100%; object-fit: cover; }

.ab-row__desc {
  /* Shrinkable, so a row with no portrait keeps the whole measure and one
     with a portrait is held to the frame's 418 rather than running under it. */
  flex: 0 1 auto;
  max-width: calc(418 * var(--u));
  /* The same air as the picture beside it, so the two tops line up and
     whichever is taller sets the foot of the open row. */
  margin: calc(16 * var(--u)) 0 calc(20 * var(--u));
  opacity: 0;
  transition: opacity 260ms var(--ease-soft);
}

/* The words arrive once there is room for them, and leave first on the way
   back, so they are never read through a closing row. The picture keeps the
   same clock as the words beside it. */
.ab-row.is-open .ab-row__desc,
.ab-row.is-open .ab-row__portrait {
  opacity: 1;
  transition: opacity 320ms var(--ease-soft) 160ms;
}

/* Icon 333:9461 — a plus on a 16 box, arms 8.667 by 0.667.

   Taken out of the row's flow. A line of capitals is two thirds of 16, so
   left in it the plus was the tallest thing on the row and set its height:
   the name sat 3.2 lower inside it than a name on a list with no plus, and
   than the eyebrow beside it. Out of the flow the row is the line again and
   the plus still rides its centre. */
/* Down from the top of the row rather than half way into it. A row carries
   the rule above it as padding, and an absolutely positioned child's 50% is
   50% of the padding box — so the plus sat in the middle of a box whose
   words are at the bottom of it, half the padding above the line it belongs
   on. The first credit had no padding and was right; the five below it were
   13 high at 1920. Measured from the words instead: the row's own air, then
   half a line of capitals, which is what `--serif-cap` is and what the name
   beside it measures. */
.ab-plus,
.ab-arrow {
  position: absolute;
  right: 0;
  top: calc(var(--row-air, 0px) + var(--serif-cap) / 2 - 8 * var(--u));
  width: calc(16 * var(--u));
  height: calc(16 * var(--u));
}

/* Associates are the one list here that goes somewhere: each name is another
   studio's site rather than a description that folds out under it. So it
   takes the mark the rest of the site gives a way out — the same mask the
   section names and More carry, in the box the plus would have stood in. */
.ab-arrow {
  background: currentColor;
  -webkit-mask: url("/assets/svg/section-arrow.svg") no-repeat center /
    calc(7.07 * var(--u));
  mask: url("/assets/svg/section-arrow.svg") no-repeat center /
    calc(7.07 * var(--u));
}

.ab-plus::before,
.ab-plus::after { content: ""; position: absolute; background: currentColor; }

/* Open, the upright arm closes into the crossbar: a plus becomes a minus. */
.ab-plus::after { transform-origin: 50% 50%; transition: transform 320ms var(--ease-soft); }
.ab-row.is-open .ab-plus::after { transform: scaleY(0); }
.ab-plus::before { left: calc(3.667 * var(--u)); top: calc(7.667 * var(--u)); width: calc(8.667 * var(--u)); height: calc(0.667 * var(--u)); }
.ab-plus::after { left: calc(7.667 * var(--u)); top: calc(3.667 * var(--u)); width: calc(0.667 * var(--u)); height: calc(8.667 * var(--u)); }

/* --- The ground under a row that answers ---------------------------------
   The same as the home page's lists and measured the same way: from the
   row's own box, its rule down to the next one. Nothing here is worked out
   from the height of a line — see the note over there for what that cost.

   All three lists. An associate goes somewhere and Offering and Credits open
   where they stand, which is a different answer to the same question the
   pointer is asking; a row that answers at all is worth lighting. Benefits
   is the one list left dark, because a benefit does nothing when it is
   clicked and should not look as though it might.

   Not while a row is open, though. An open credit is a portrait and a bio a
   couple of hundred tall and the row's box has grown to hold them, so the
   ground would grow with it and black out the very thing the reader has just
   asked to read. By then the plus has become a minus, which says enough.
------------------------------------------------------------------------- */

/* How far under the row the next rule is. Every list on this page keeps it
   as the gap between its rows; the narrow frame gives Offering and Credits
   their air as padding instead and sets this to nothing — see below. */
.ab-list { --hov-drop: var(--list-air); }

.ab-offering .ab-row::before,
.ab-credits .ab-row::before,
.ab-associates .ab-row::before {
  content: "";
  position: absolute;
  left: 0;
  right: var(--hov-run);
  top: 0;
  bottom: calc(-1 * var(--hov-drop));
  background: var(--hov-ground);
  opacity: 0;
  pointer-events: none;
  transition: opacity 180ms var(--ease-soft);
}

/* The first row of a list has the section's rule over it rather than one of
   its own, and no padding under it — so its ground reaches up for it. */
.ab-offering .ab-row:first-child::before,
.ab-credits .ab-row:first-child::before,
.ab-associates .ab-row:first-child::before { top: calc(-1 * var(--hov-drop)); }

/* An associate that has somewhere to go takes the whole row as its target,
   not the half-dozen letters standing in it. The link is left unpositioned
   so that the box it throws over the row measures from the row rather than
   from itself — which is also what lets the name inside go on being
   positioned for the painting order. */
/* The anchor passes the row's height straight through to the name.

   The row is a flex box over this anchor, so the anchor is what gives the
   row its height — and an anchor holding a bare inline name gives it a whole
   line box, twenty-one where the other lists stand on a cap box of nine and
   a half. That is the wider pitch: sixty-five between associates against
   fifty-four between credits, in a column meant to keep one rhythm the whole
   way down.

   It is also the arrow. The mark is placed half a cap below the row's top,
   which is the optical middle of a name whose box begins at the cap — and on
   a line box the top is three and a half pixels above where the capitals
   begin, so the mark sat that much high of everything it lines up with. The
   two were never separate faults.

   The name is made a block so that its own trim governs, which is how the
   credits name comes to be one: a flex item, blockified by its parent.
   Trimming the anchor instead is the near miss — the trim is cut from the
   element's own font, and the anchor inherits the row's sixteen while the
   name is set in fourteen, so the row came out a pixel and a bit too tall
   and the arrow kept most of its offset. */
.ab-assoc {
  display: block;
  color: inherit;
  text-decoration: none;
}

.ab-associates .ab-row__name { display: block; }

.ab-assoc::after {
  content: "";
  position: absolute;
  inset: 0;
}

/* And a click on the mark is a click on the row. */
.ab-associates .ab-arrow { pointer-events: none; }

/* Only where there is a pointer that can hover — see the note on the home
   page's lists. */
@media (hover: hover) {
  .ab-offering .ab-row:hover:not(.is-open)::before,
  .ab-credits .ab-row:hover:not(.is-open)::before,
  .ab-associates .ab-row:hover::before { opacity: 1; }

  /* One declaration turns the whole row: the name, the role and the plus all
     take their colour from here — see `.ab-row__name` and `.ab-plus`. */
  .ab-offering .ab-row:hover:not(.is-open),
  .ab-credits .ab-row:hover:not(.is-open),
  .ab-associates .ab-row:hover { color: var(--hov-ink); }
}

/* The indent belongs to the name and to nothing else.

   It was the row's padding, which moved everything standing in the row — on
   a credit that took the role along with the name, and the role is not
   indented, it is in a column. The column is the name's own width, 235, and
   a padding inside a border box spends the name's width rather than adding
   to it: the letters come in by ten and the role stays exactly where it was.

   The marks never moved either way. The plus and the arrow are out of the
   flow and measured from the row's padding box, and so is the ground. */
/* Longer than the ground it arrives into, and on an even curve rather than
   the site's reveal easing. `--ease-soft` puts most of a move into its first
   fifth, which on a word rising into a mask reads as crisp and on ten pixels
   of travel reads as a twitch and then nothing. The ground comes up in its
   own 180; the name takes a little longer to settle into it, which is the
   right way round — the thing that moves should be the thing you can
   follow. */
.ab-offering .ab-row__name,
.ab-credits .ab-row__name,
.ab-associates .ab-row__name { transition: padding-left 320ms var(--ease-glide); }

@media (hover: hover) {
  .ab-offering .ab-row:hover:not(.is-open) .ab-row__name,
  .ab-credits .ab-row:hover:not(.is-open) .ab-row__name,
  .ab-associates .ab-row:hover .ab-row__name { padding-left: var(--hov-indent); }
}

/* Except on the narrow frame, where the role is not in a column beside the
   name but on its own line under it — and two stacked lines that move by
   different amounts are not a credit coming forward, they are a credit
   coming apart. Where they are stacked they travel together.

   Strictly the narrow frame and nowhere else: the paragraph above is why the
   wide one must not do this, and the two rules are the same reasoning read
   against two layouts. The role takes the name's clock as well, or they
   would arrive at different times having set off together. */
@media (hover: hover) and (max-width: 1023px) {
  .ab-credits .ab-row__role { transition: padding-left 320ms var(--ease-glide); }

  .ab-credits .ab-row:hover:not(.is-open) .ab-row__role {
    padding-left: var(--hov-indent);
  }
}

/* --- The portrait that rides the cursor across Credits -------------------
   Hovering a credit brings that person's picture up under the pointer and
   carries it along. It is one container for the whole list, not one per row:
   moving from a name to the name under it leaves the frame where it is and
   changes what is in it, which is what makes the list read as one object with
   six faces rather than six things appearing and disappearing.

   So the frame fades on the way in and out of the list, and the pictures
   inside it cross-fade between rows — two layers, the front one opaque, and
   they take turns. The position is written by app.js on a frame loop that
   eases toward the pointer rather than pinning to it, which is the float.

   Fixed, so the scroll does not drag it; and `pointer-events: none`, so it
   never stands between the reader and the row it belongs to.
------------------------------------------------------------------------- */

.ab-float {
  position: fixed;
  z-index: 20;
  top: 0;
  left: 0;
  width: calc(118 * var(--u));
  aspect-ratio: 118 / 158;
  opacity: 0;
  pointer-events: none;
  transition: opacity 280ms var(--ease-soft);
  will-change: transform;
}

.ab-float.is-on { opacity: 1; }

.ab-float__img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* The frame is already out of reach; said again on the picture so that a
     picture is never the thing between the reader and the row. */
  pointer-events: none;
  opacity: 0;
  transition: opacity 360ms var(--ease-soft);
}

.ab-float__img.is-front { opacity: 1; }

/* A pointer that cannot hover has nothing to follow, and the narrow frame
   stands the portrait over the words rather than beside them — see below. */
@media (max-width: 1023px), (hover: none) {
  .ab-float { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .ab-float,
  .ab-float__img { transition: none; }
}

/* --- Working here (371:21655) -------------------------------------------
   The photograph holds at the top of the window while the copy beside it
   runs past. Its column stretches, so the hold lasts the whole band.
------------------------------------------------------------------------- */

/* 808:50504 — the picture is a small one at the page's own left edge now,
   not a tall column beside the copy: 225 across and 302 down, on 20, with
   the copy starting at 725. The gap is what stands between the two. */
.ab-band {
  display: flex;
  gap: calc(480 * var(--u));
  margin-top: calc(220.5 * var(--u));
}

/* Stretched, not shrunk to the picture: the hold needs a column as tall as
   the band to travel in, and a column the height of its own contents gives
   it nowhere to go. 808:50505 says the same thing the way a frame can — the
   picture in a box taller than itself. */
.ab-media {
  flex: 0 0 calc(225 * var(--u));
  align-self: stretch;
  margin: 0;
}

/* The picture holds at the top of the window while Working Here and Benefits
   run past it, and lets go at the foot of the band.

   Not at the top of the window, though: this frame puts the picture in the
   same column as the navigation, so it stops where a shut panel ends. */
.ab-media__sticky {
  position: sticky;
  /* Where the panel parks, its logo row, and a gutter under that — the
     panel's own terms, not the frame's, so the clearance holds at every
     width instead of closing to eight by 1024. */
  top: calc(var(--pad) + 48 * var(--snav-u) + var(--pad));
  display: block;
}

.ab-media__img {
  display: block;
  height: calc(302 * var(--u));
  overflow: hidden;
}

/* 743:44427 — at the top of the band now rather than 423 down it, so the
   copy starts level with the photograph beside it. */
.ab-copy {
  display: flex;
  align-items: flex-start;
  padding: var(--rule-gap) 0 calc(20 * var(--u));
  border-top: 0.5px solid var(--rule);
}

.ab-copy .ab-eyebrow { flex: 0 0 calc(235 * var(--u)); }

.ab-copy__right {
  display: flex;
  flex: 1 1 auto;
  flex-direction: column;
  gap: calc(32 * var(--u));
}

/* 808:50513 — a line with the page's own mark after it, where a black bar
   used to be. Four apart, as More is on the home page, and the mark takes
   the colour of the words. */
.ab-join {
  align-self: flex-start;
  display: inline-flex;
  align-items: center;
  gap: calc(4 * var(--u));
  color: inherit;
  font-family: var(--serif);
  font-size: var(--fs-body);
  line-height: var(--lh-serif);
  text-decoration: none;
}

.ab-join:hover { text-decoration: underline; text-underline-offset: 0.2em; }

.ab-copy__text { width: calc(433 * var(--u)); }

/* Nagiation/Button (371:21664) */
.ab-btn {
  display: flex;
  align-items: center;
  justify-content: center;
  height: calc(30 * var(--u));
  background: var(--ink);
  color: var(--paper);
  font-family: var(--mono);
  font-size: var(--fs-label);
  line-height: var(--lh-mono);
  letter-spacing: 0.02em;
  text-transform: uppercase;
  text-decoration: none;
}

/* The narrow frame's second Apply, under Benefits. It shows and hides on its
   own rather than through .m-only, whose `display: block !important` would
   beat the flex above it and drop the word into the top left corner of the
   bar instead of the middle of it. */
.ab-btn--m { display: none; }

.ab-benefits { margin-top: calc(70.5 * var(--u)); }
.ab-benefits .ab-eyebrow { flex: 0 0 calc(235 * var(--u)); }

/* --- Thoughts (371:21648) ------------------------------------------------ */

/* 743:44417 — the label sits over the cards now rather than beside them,
   and they run the whole measure. */
.ab-thoughts {
  display: flex;
  flex-direction: column;
  gap: calc(20 * var(--u));
  /* 2541, off the foot of Benefits. */
  margin-top: calc(180 * var(--u));
  padding-top: var(--rule-gap);
  border-top: 0.5px solid var(--rule);
}

.ab-thoughts__label { flex: 0 0 auto; }

/* Three across the measure at 460 apiece; with two on the page they sit at
   the left of it rather than stretching to fill. */
.ab-cards {
  display: flex;
  gap: calc(10 * var(--u));
  width: 100%;
}

.ab-card {
  flex: 0 0 calc(460 * var(--u));
  min-width: 0;
}

/* The column is the link's, not the card's: a thought leads somewhere, and
   what it leads to is the picture and the words together. A thought with
   nowhere to go yet is the same box without the anchor — see About. */
.ab-card__link {
  display: flex;
  flex-direction: column;
  gap: calc(10 * var(--u));
}

.ab-card__img { display: block; aspect-ratio: 1; overflow: hidden; }
.ab-card__img img { width: 100%; height: 100%; object-fit: cover; }

.ab-card__desc {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  padding: calc(5 * var(--u)) 0;
}

/* Centred on the title's cap the way the work card's name is, and the way
   Office and Contact are on the home page — see `.card__name`. The two of
   padding that used to stand here was the same guess, and wrong by the same
   amount. */
.ab-card__who {
  flex: 0 0 calc(98 * var(--u));
  padding-top: var(--label-nudge);
}

.ab-card__title { flex: 1 1 auto; text-box: trim-both cap alphabetic; }

/* The hover's shrink is a proportion, so it has to be this grid's own: ten
   off 460 here, where the work grid's is ten off 381.667. Written per grid
   rather than once, because one figure for both would be ten on one page and
   twelve on the other — and the frame's ten is a margin, not a percentage.
   Both read exactly ten at 1440, and the same number as each other at every
   width their cards still take a third of the measure. */
.ab-card .ctile { --card-shrink: 0.9565217; }

/* --- About on narrow screens — Figma node 365:17958 (393 x 3667) --------
   One column on the 10 gutter, the label in the first 26.3% and the value
   in the rest. The two long lists drop their rules and run as plain stacks.
------------------------------------------------------------------------- */

@media (max-width: 1023px) {
  .page--about {
    height: auto;
    padding: 0;
    background: var(--cream);
  }

  /* The wide frame's rows dissolve into the narrow frame's single column. */
  .ab-band,
  .ab-rowset,
  .ab-side,
  .ab-main { display: contents; }

  .ab-col {
    --col-gap: 23px;
    gap: var(--col-gap);
    margin: 1px var(--pad) 0;
  }

  /* --- Info, Working (365:17960, 365:18006) --- */
  .ab-info,
  .ab-copy {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    margin-top: 0;
    /* The air a rule gives what it heads, as on every other frame. It had
       been 20 here while the lists under it were on the site's own figure. */
    padding: var(--rule-gap) 0;
  }

  .ab-info .ab-eyebrow,
  .ab-copy .ab-eyebrow { flex: 0 0 auto; }

  .ab-info__copy,
  .ab-copy__right { flex: 0 0 73.9%; }
  .ab-copy__text { width: auto; }
  .ab-info__copy,
  .ab-copy__text { letter-spacing: 0.02em; }

  /* --- Photography (365:18004) --- */
  .ab-media { flex: 0 0 auto; margin: 0 0 0 26.3%; }
  .ab-media__sticky { position: static; padding-top: 0; }
  .ab-media__img { height: 366px; }

  /* --- Ruled lists: Offering, Credits (365:17965, 365:17975) --- */
  .ab-block {
    display: flex;
    margin-top: 0;
    padding: 0;
  }

  /* After `.ab-block`, which zeroes the top margin these rows carry on the
     wide frame — the two selectors weigh the same, so this has to follow it.

     Offering under Info is the home page's Selected works under Studio, so
     it takes the same break. Measured to the rule from the last line of the
     copy, which is what the eye reads the gap as: the column's own gap and
     the air Info keeps under itself come off it. */
  .ab-offering {
    margin-top: calc(var(--sec-air) - var(--col-gap) - var(--rule-gap));
  }

  .ab-block .ab-eyebrow { flex: 0 0 26.3%; padding-top: 20.75px; }

  .ab-list { gap: 0; }
  .ab-list li + li { --row-air: 0px; padding-top: 0; border-top: 0; }

  /* A floor rather than a fixed height: an opened row has to be able to
     grow past it. */
  /* This frame gives its rows a height outright and pads every one of them,
     so it sets its own air per list and wants no gap between them. */
  .ab-offering .ab-list,
  .ab-credits .ab-list { gap: 0; }

  /* Every row is padded on this frame, the first one too, and a credit's
     name and role stack — so the middle of the row is well below the line
     the plus belongs on. Same measurement as the wide frame makes: this
     row's air, then half a line. */
  .ab-offering .ab-list li { --row-air: var(--list-air); min-height: 54px; padding-top: var(--row-air); align-items: flex-start; }
  .ab-offering .ab-list li + li { border-top: 0.5px solid var(--rule); }

  .ab-credits .ab-list li { --row-air: var(--list-air); min-height: 77px; padding-top: var(--row-air); align-items: flex-start; }
  .ab-credits .ab-list li + li { border-top: 0.5px solid var(--rule); }

  /* Air as padding here rather than a gap, so a row's own box already ends
     where the next one's rule begins and the ground has nothing to reach
     into — see `--hov-drop`. */
  .ab-offering .ab-list,
  .ab-credits .ab-list { --hov-drop: 0px; }
  /* The wide frame gives the name a 235 column. These details stack, so a
     basis of 235 is a height there rather than a width — which is exactly
     what it became until this put it back. */
  .ab-credits .ab-row__details { flex-direction: column; align-items: flex-start; gap: 12px; }
  .ab-credits .ab-row__name { flex: 0 1 auto; }

  /* The li's own alignment does not reach inside the head, so it is set
     again where the line actually is. */
  .ab-row__head { align-items: flex-start; }

  /* A credit is one line on the wide frame and two on this one — the name
     with the role under it — so half a cap down from the top of the row is
     the middle of the name rather than the middle of the credit. The plus
     belongs to both lines, so it is centred on the box that holds them: the
     row for a credit that does not open, the head for one that does, which
     is the same pair either way. */
  .ab-credits .ab-plus {
    top: 50%;
    transform: translateY(-50%);
  }
  /* No room to stand a picture beside the words here, so it stands over
     them — at the size the wide frame sets it, which is already about two
     fifths of this column. */
  .ab-row__moreinner {
    flex-direction: column;
    gap: 16px;
  }

  /* Stacked, so the air above belongs to whichever comes first and the air
     below to the words either way; the 16 between them is the column's gap.
     In this frame's own pixels, not the wide frame's units — those stop
     scaling at the fold and would come to ten here. */
  .ab-row__portrait { flex: 0 0 auto; width: 118px; margin: 16px 0 0; }
  .ab-row__desc { max-width: none; margin: 16px 0 20px; }
  .ab-row__portrait + .ab-row__desc { margin-top: 0; }

  /* --- Associates, Benefits (365:18001, 365:18010) ---
     Ruled between the rows, as they are on the wide frame and as Offering
     and Credits are on this one. They were drawn here as a plain stack of
     lines with the rules switched off, which left these two the only lists
     on the page without them. 20.75 either side of the rule: the gap is the
     room above it, the padding the room below. */
  .ab-associates { padding: 20.75px 0 0; }

  .ab-benefits {
    display: flex;
    flex-wrap: wrap;
    gap: 29px 0;
    padding: 20px 0;
  }

  .ab-associates .ab-eyebrow,
  .ab-benefits .ab-eyebrow { padding-top: 0; }
  .ab-benefits .ab-eyebrow { flex: 0 0 26.1%; }

  .ab-associates .ab-list,
  .ab-benefits .ab-list { flex: 0 0 73.9%; gap: var(--list-air); }

  .ab-associates .ab-list li,
  .ab-benefits .ab-list li { height: auto; padding: 0; border-top: 0; }

  /* `--row-air` with the padding, every time — see the note on `.ab-list li
     + li`. This frame sets the row's copy to nought at the top of the block
     and these two lists then wrote the padding back without it, so the mark
     on every row but the first measured from a top edge 22 above the words
     and sat on the rule instead of the name. */
  .ab-associates .ab-list li + li,
  .ab-benefits .ab-list li + li {
    --row-air: var(--list-air);
    padding-top: var(--row-air);
    border-top: 0.5px solid var(--rule);
  }

  /* The home page's button, to the letter — 801:49381 and `.hsec > .mbtn`:
     the width of the column it closes, hard against its right edge, 30 tall,
     29 of air over it. `.ab-copy__right` is already that column, so the
     width is simply all of it; the 29 is the column's own gap, which had
     nothing to space while Join us was the only other thing in here and the
     narrow frame hides that. A basis of 100% was right under Benefits, which
     laid its children in a row — in this column it would have been a height.
   */
  .ab-copy__right { gap: 29px; }
  .ab-btn--m { display: flex; width: 100%; height: 30px; }

  /* --- Thoughts (365:18015) --- */
  .ab-thoughts {
    flex-direction: column;
    gap: 20px;
    margin-top: 0;
    padding: 20px 0;
  }

  .ab-thoughts__label { flex: 0 0 auto; }
  /* The wide frame gives a card its 460; here they share what there is. */
  .ab-cards { width: auto; gap: 10px; }
  .ab-card { flex: 1 1 0; }
  .ab-card__link { gap: 12px; }

  .ab-card__desc {
    flex-direction: column;
    gap: 14px;
    padding: 0;
  }

  .ab-card__who { flex: 0 0 auto; padding-top: 2px; }
}

/* ==========================================================================
   Work — Figma node 371:21110 (wide, 1440) and 373:23284 (narrow, 393)
   Every project in both; the wide frame lays them in three-up rows on an 80
   rhythm, the narrow one runs them in a single column on 50. Card heights
   come from Sanity, which is what gives the grid its uneven rhythm.
   ========================================================================== */

/* Height comes from the grid, which is in flow; the nav floats over it. */
.page--work {
  background: var(--paper);
  overflow: visible;
  padding-top: calc(20 * var(--u)); /* keeps .wmain's offset from collapsing out to body */
}


/* The column starts on the frame's 255, measured in the nav's unit so it
   keeps the frame's 9 clear of the panel at every width. */
.wmain {
  position: relative;
  margin: 0 var(--pad) 0 calc(20 * var(--u) + 235 * var(--snav-u));
}

.whead {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  padding-top: calc(20 * var(--u));
  border-top: 0.5px solid var(--rule);
}

.whead__title { width: calc(236 * var(--u)); }
.whead__count { flex: 1 1 auto; text-align: right; }

/* A grid rather than rows of three, so the column count can answer the
   width. 20.8 below the head puts the first row on the frame's 68. */
.wgrid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  align-items: start;
  column-gap: calc(10 * var(--u));
  row-gap: calc(80 * var(--u));
  margin-top: calc(20.8 * var(--u));
}

/* The frame groups the cards in rows; the grid places them itself. */
.wrow { display: contents; }

.card { min-width: 0; margin: 0; }
.card__link { display: block; }


/* Heights come from the frame per card; as a ratio they scale with width. */
.card__img {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 375 / var(--ih);
  overflow: hidden;
}

/* The card's own artwork fills the card. The marks laid over it — the swoosh,
   the Ellis and Funko lockups — size and place themselves, so they are kept
   out of this: without the exclusion this rule outranks theirs on specificity
   and stretches them over the whole card. */
.card__img > img:not(.card__inner),
.card__img > video { display: block; width: 100%; height: 100%; object-fit: cover; }

.card__img > .card__inner { display: block; }

/* Centred with `translate` rather than `transform`, because the reveal sets
   `transform: scale()` on every picture in a frame and would otherwise replace
   the centring outright — leaving the mark's corner on the middle instead of
   its middle. The two properties compose. */

.card__inner { position: absolute; max-width: none; }

.card__inner--swoosh {
  left: 50%;
  top: 50%;
  width: 48.53%;
  height: auto;
  translate: -50% -50%;
}

.card__img--vsco::after {
  content: "";
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.2);
}

.card__img--ellis { background: linear-gradient(to bottom, #00161c, #006682); }

.card__inner--ellis {
  left: 50%;
  top: 10.92%;
  width: 35.47%;
  height: auto;
  translate: -50%;
}

.card__img--funko { background: linear-gradient(to bottom, #1a1682, #489dcf); }

.card__inner--funko {
  left: 50%;
  top: 50%;
  width: 38.4%;
  height: auto;
  translate: -50% -50%;
}

.card__text {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  margin-top: calc(10 * var(--u));
  padding: calc(5 * var(--u)) 0;
}

/* The name and the tagline are set in different faces at different sizes,
   and a flex row lines up the tops of their boxes, not the tops of their
   letters. `.label` is trimmed to its cap already, so its box top is the
   top of E; `.h-body` was not, so above its F sat half of 1.5 leading on
   18 — a drop of five or six that no eye reads as a row. It was being
   answered with two of padding on the name, which moved the right thing in
   the right direction and nowhere near far enough, and which would have had
   to be re-guessed at every width, since the gap is a proportion of a font
   size that clamps.

   Trimming the tagline the same way puts both boxes at the cap, and from
   there the row is aligned the way every other mono-against-serif row on
   the site is — Office and Contact on the home page, a name against its
   role in the credits: not cap top to cap top, which leaves the smaller
   face riding high against a cap half again its height, but the small cap
   centred in the tall one. `--label-nudge` is that half-difference, and it
   is on the label rather than on the row because the tagline may run to a
   second line, and a row centred as a whole would then hang the label
   against the middle of two. */
.card__name {
  flex: 0 0 calc(98 * var(--u));
  padding-top: var(--label-nudge);
}

.card__tag {
  flex: 1 1 auto;
  min-width: 0;
  text-box: trim-both cap alphabetic;
}

/* Below the frame's own width the eyebrow's column shrinks with everything
   else, but the longest names in it do not shrink as fast — `DOT SWOOSH`
   fills its 98 at 1440 and overruns it by 1366, so the tagline begins hard
   against the end of the name rather than at a column of its own. Up to
   twenty more for the name to end in, which is the tagline's start moving
   right and not the eyebrow moving at all.

   Arrived at over the range rather than at a line in it: nothing at 1440,
   where the frame is the frame and the column is already wide enough, and
   the whole twenty by 1024, where the column has lost thirty of its own.
   A step at 1439 would have been a column that jumps on a drag of the
   window, and the crowding it answers comes on gradually.

   Twenty flat at the end of the ramp, not twenty scaled: what has to clear
   is a word set in a face that stops scaling at 10, so the room it needs
   stops scaling too.

   Written as a line rather than as a fraction because calc cannot divide by
   a length: 20 over the 416 between the two widths is 0.0480769 of every
   pixel under 1440. The clamp is what holds it at nothing above the frame's
   width and at twenty below 1024, so the rule can be stated once for the
   whole desktop range. `--vw` and not `100vw`, which counts a scrollbar the
   page does not have. */
@media (min-width: 1024px) {
  .card__name {
    flex-basis: calc(
      98 * var(--u) +
      clamp(0px, (1440px - var(--vw, 100vw)) * 0.0480769, 20px)
    );
  }
}

/* --- A card tile under the pointer (915:52791) ---------------------------
   Two grids are drawn this way — the work page's projects and the thoughts
   at the foot of About — so the treatment is written once, against a set of
   names of its own, and each grid wears them beside the classes that lay it
   out. `.ctile` is the thing that is hovered, `__pic` the picture it takes
   in, `__words` the line under it, `__key` the eyebrow at its left and
   `__line` the rest.

   The frame draws the hovered tile as a black ground with the picture set
   inside it rather than running across it: a 10 inset on three sides, the
   description where it already was, and 40 of ground under it. Unhovered
   there is no ground and the picture is the tile.

   Which is done by shrinking the picture rather than by padding the tile.
   Padding would move the tile's own edges, and a tile whose edges move is a
   row of tiles that move with it — the one thing a hover must not do. A
   scale paints smaller inside a box that has not changed, so the grid never
   hears about it.

   `.ctile__pic` is a box of its own and not the picture's own frame because
   that frame is the reveal's, with a clip and a transition already on it; a
   second transition on the same element replaces the first outright.

   The description turns over with the ground beneath it rather than being
   told a second colour — `difference` is the site's own answer for a mark
   that has to read on either, and it tracks the ground continuously instead
   of flipping halfway through the fade. Isolated so it reads the tile and
   not the page. */

/* The tile measures itself, so the inset can be read off the tile rather
   than off the page. The containers are the grid items; a container cannot
   be asked about its own width, only an ancestor's. */
.card,
.ab-card { container-type: inline-size; }

.ctile {
  color: inherit;
  text-decoration: none;

  /* What the shrink actually leaves at the left and right edges: half of
     what the scale takes off the tile's own width. Said here, from that
     width, rather than as a figure off the page's `--u`, because the two are
     not the same ruler. The sides have always been this — a scale can only
     take a proportion — while the top was `10 * --u`, and the further the
     layout got from the frame the further they parted: a quarter of a pixel
     at 1366, four tenths at 1200, and at 1024, where the work grid drops to
     two and the card gets wider while the page gets smaller, the sides were
     9.65 against a top of 7.03. Two and a half pixels of difference on a ten
     pixel margin, which is the one thing a margin cannot have.

     Measured off the tile they cannot part: the top is given the same number
     the sides take. It is the frame's 10 at 1440 and it follows the tile
     from there, which is what every other measure on this site does. */
  --card-inset: calc(100cqw * (1 - var(--card-shrink)) / 2);
}

/* A block whether or not there is a pointer to hover with: a span left
   inline around a block child is a box the layout has to invent anonymous
   ones around, and it has no reason to be one. */
.ctile__pic { display: block; }

.ctile[href]:hover .ctile__key { text-decoration: underline; }

@media (hover: hover) and (pointer: fine) {
  .ctile {
    position: relative;
    isolation: isolate;
  }

  .ctile::before {
    content: "";
    position: absolute;
    inset: 0 0 calc(-40 * var(--u));
    z-index: -1;
    background: var(--ink);
    opacity: 0;
    transition: opacity var(--card-hover-ms) var(--ease-glide);
  }

  /* Taken in from the top rather than from the middle, so the inset over the
     picture is the frame's ten and not whatever half the scale happens to
     leave. From the middle it was a proportion of the tile's own height —
     nine on one, eleven on another, seven on a third — which is three
     different insets on one row. The origin puts the top edge where it was
     and the translate then sets it down by exactly the inset; the sides are
     the scale's own, and now the same number. Still one transform, so still
     nothing to reflow and nothing recropped. */
  .ctile__pic {
    transform-origin: 50% 0;
    transition: transform var(--card-hover-ms) var(--ease-glide);
  }

  .ctile[href]:hover::before,
  .ctile[href]:focus-visible::before { opacity: 1; }

  .ctile[href]:hover .ctile__pic,
  .ctile[href]:focus-visible .ctile__pic {
    transform: translateY(var(--card-inset)) scale(var(--card-shrink));
  }

  /* The words, and the words' own children.

     `.label` and `.h-body` each name a colour of their own, so the white
     said on the box they sit in never reached them: black type differenced
     against a black ground is black, which is why the description stayed
     dark on a tile that had gone dark under it. Said on the two that carry
     the type, since those are the ones that were arguing. */
  .ctile[href]:hover .ctile__words,
  .ctile[href]:focus-visible .ctile__words { mix-blend-mode: difference; }

  .ctile[href]:hover .ctile__key,
  .ctile[href]:hover .ctile__line,
  .ctile[href]:focus-visible .ctile__key,
  .ctile[href]:focus-visible .ctile__line { color: var(--paper); }

  /* The eyebrow comes in by the same ten the picture does, so that on a
     hovered tile the two left edges are one edge: the ground's own margin
     rather than two measurements that happen to be near each other. Moved
     rather than padded — padding would widen the eyebrow's column and push
     the line beside it along too, and that line is where the frame puts it. */
  .ctile__key { transition: transform var(--card-hover-ms) var(--ease-glide); }

  .ctile[href]:hover .ctile__key,
  .ctile[href]:focus-visible .ctile__key {
    transform: translateX(var(--card-inset));
  }

  /* The shrinking is the flourish, not the signal: a reader on the keyboard
     is told where they are by a mark that does not depend on noticing a few
     percent of scale. */
  .ctile[href]:focus-visible {
    outline: 1px solid var(--ink);
    outline-offset: calc(4 * var(--u));
  }

  @media (prefers-reduced-motion: reduce) {
    .ctile::before,
    .ctile__pic,
    .ctile__key { transition: none; }
  }
}

@media (max-width: 1023px) {
  /* The frame opens the column below the nav, which floats over the page.
     It is the page's padding, not a margin on the column: a margin there
     collapses out to the body and leaves its grey behind the nav. */
  .page--work { padding: var(--ntop) 0 0; }

  .wmain {
    position: static;
    width: auto;
    margin: 0 var(--pad);
    padding-bottom: 50px;
  }

  .whead { padding-top: 20px; }
  .whead__title { width: auto; }

  .wgrid {
    column-gap: 10px;
    row-gap: 50px;
    margin-top: 20px;
  }

  .card__img { aspect-ratio: 375 / var(--ih); }
  .card__name { flex: 0 0 98px; }
  .card__text { margin-top: 10px; padding: 5px 0; }
}

/* --- Column count --------------------------------------------------------
   Three up takes the cards under 300 on a small laptop — narrower than the
   narrow frame's own card, which reads as a jump at the breakpoint. Two up
   from there down through the tablets, one only on the phones.
------------------------------------------------------------------------- */

@media (max-width: 1199px) {
  .wgrid { grid-template-columns: repeat(2, 1fr); }
}

@media (max-width: 599px) {
  .wgrid { grid-template-columns: 1fr; }
}

/* ==========================================================================
   Project detail template — Figma node 102:4090
   A block library rather than one fixed page: Hero, Overview, Text, Media and
   four Text+Media variants, stacked in the frame's order. Media is drawn as
   the frame's #bcbcbc placeholder until real assets are dropped in.
   ========================================================================== */

:root { --placeholder: #bcbcbc; }

.page--project { overflow: visible; padding: calc(20 * var(--u)) calc(20 * var(--u)) 0; }

/* --- A locked project ---------------------------------------------------
   The door, where the page would have been. It keeps the project frame's own
   measure and type so it reads as part of the site rather than as an error,
   and says as little as it can: the name, and a place to put the password.
------------------------------------------------------------------------- */

/* --- A locked project (726:43123) ---------------------------------------
   The page's own overview row, with the password where the about copy would
   be: the same hairline above it, the same title, the same measure. It is
   inset past the nav because at the top of a page the nav is still open and
   standing on the left of the measure — every other block is drawn to sit
   under it, and this one small row is not.

   The frame's measure: 1164 from 256, with the row 353 of title and 811 of
   asking — a 117 label, a 459 field, a 10 gap and a 225 button.
------------------------------------------------------------------------- */

/* Named on both classes: `.pblock` resets the margin at the same weight, and
   sets it whole, so a margin-left of its own would be thrown away. */
.pblock.plock {
  display: flex;
  align-items: flex-start;
  /* `.pblock` is 100% wide, which with an indent is 100% plus the indent and
     hangs off the measure. The row takes what is left instead. */
  width: auto;
  margin-left: calc(var(--snav-w) + 10 * var(--u));
  padding-top: calc(14 * var(--u));
  /* What the frame leaves between the row and the footer band. */
  padding-bottom: calc(334 * var(--u));
  border-top: 0.5px solid var(--rule);
}

/* The title hugs its own width; the asking is pinned to the right, so a long
   name runs out of room rather than pushing the button off the measure. */
.plock__name {
  flex: 0 1 auto;
  min-width: 0;
  padding-top: calc(10 * var(--u));
}

/* Pinned to the frame's 811 — a 117 label, a 459 field, a 10 gap and a 225
   button — rather than left to its content. Left to its content, the line
   that says the password was wrong sets the width instead: it is wider than
   the field it sits under, so the whole row grew leftwards and walked into
   the title. A message about a mistake must not be able to move the thing it
   is a message about. */
.plock__ask {
  flex: 0 0 calc(811 * var(--u));
  display: flex;
  align-items: flex-start;
  flex-wrap: wrap;
  margin: 0 0 0 auto;
  padding-top: calc(2 * var(--u));
}

.plock__label {
  flex: 0 0 calc(117 * var(--u));
  padding-top: calc(10 * var(--u));
  color: var(--ink);
}

.plock__field {
  flex: 0 0 calc(459 * var(--u));
  height: calc(29 * var(--u));
  padding: 0 0 calc(4 * var(--u));
  border: 0;
  border-bottom: 0.5px solid var(--rule);
  border-radius: 0;
  background: none;
  font-family: var(--mono);
  font-size: var(--fs-label);
  letter-spacing: 0.18em;
  color: var(--ink);
}

.plock__field:focus { outline: none; border-bottom-color: var(--ink); }

.plock__go {
  flex: 0 0 calc(225 * var(--u));
  display: flex;
  align-items: center;
  justify-content: center;
  height: calc(30 * var(--u));
  margin-left: calc(10 * var(--u));
  padding: 0;
  border: 0;
  background: var(--ink);
  color: var(--paper);
  cursor: pointer;
  transition: opacity 160ms var(--ease-soft);
}

.plock__go:hover { opacity: 0.8; }

/* Under the field, where it is read, rather than anywhere it would move the
   row it belongs to. */
/* The width the field and the button between them take, so it starts under
   the field and ends on the measure: a hundred percent would be the whole
   row again, and with the label's 117 in front of it, 117 past the edge. */
.plock__wrong {
  flex: 0 0 calc(694 * var(--u));
  margin: calc(10 * var(--u)) 0 0 calc(117 * var(--u));
  color: #b3261e;
}

.plock__shut { padding-top: calc(10 * var(--u)); color: var(--ink); opacity: 0.5; }

/* --- Credits (377:25945) — the list hangs off the right of the measure. --- */

.pcredits {
  display: flex;
  justify-content: flex-end;
  gap: calc(10 * var(--u));
  margin-top: calc(200 * var(--u));
  padding-top: calc(40 * var(--u));
  border-top: 0.5px solid var(--rule);
}

/* 225 and 460 either side of a 10 gutter is the frame's 695, held to the
   right of the measure (401:27381). */
.pcredits__label { flex: 0 0 calc(225 * var(--u)); }

.pcredits__list {
  display: flex;
  flex: 0 0 calc(460 * var(--u));
  flex-direction: column;
  gap: calc(20 * var(--u));
  margin: 0;
  padding: 0;
  list-style: none;
}

.pcredits__list li + li {
  padding-top: calc(20 * var(--u));
  border-top: 0.5px solid var(--rule);
}

.pcredits__row { display: flex; align-items: center; justify-content: space-between; }
.pcredits__name,
.pcredits__role { flex: 1 1 0; min-width: 0; text-box: trim-both cap alphabetic; }
.pcredits__role { text-align: right; }

/* The frame pins each block to an absolute top. In flow the blocks keep the
   same 10 gap, which is what those offsets amount to between media. */
.pstack {
  --stack-gap: calc(20 * var(--u));
  display: flex;
  flex-direction: column;
  gap: var(--stack-gap);
}

.pblock { position: relative; width: 100%; margin: 0; }

/* The frame leaves 20 under the hero, 120.5 under the overview and 160
   between everything after it. The 20 is the flex gap; the rest is carried
   by the blocks, so the hero keeps its own. These have to sit below
   `.pblock` — it resets the margin at the same weight. */
.pb-sec,
.pb-full { margin-top: calc(140 * var(--u)); }

.pb-overview + .pb-sec,
.pb-overview + .pb-full { margin-top: calc(100.5 * var(--u)); }

/* A run of full-width assets is one piece of the page rather than several,
   so what separates them is the 4 the row itself uses between its pictures,
   not the 160 that separates one part of the story from the next. Mixed in
   with a section, each keeps the 160 — only a pair of these closes up.

   Taken back rather than set: a flex gap belongs to the container, and there
   is no way to lift it for a single pair from the item's side. */
.pb-full + .pb-full { margin-top: calc(4 * var(--u) - var(--stack-gap)); }

/* The opening image is measured against the nav beside it, not against the
   width of the page.

   It was `540 * --u`, which is the page's own unit and shrinks with the
   window — while the nav does not: `--snav-u` is floored at 1 below 1440, so
   from the fold up to that width the nav is exactly as tall as it is at
   1440. The two therefore walk away from each other on the way down. At 1100
   the picture ended fifty-nine pixels above the foot of the nav and at 1030
   eighty-six, with the menu's last two rows standing over the name and the
   overview under it. Which is not the nav overlapping the page: it is the
   picture it was supposed to be standing on having shrunk out from under it.

   So the height is the nav's foot plus a gap. The foot is the page's own 20
   at the top, the hairline between the panel and the menu, and 447 of nav in
   the nav's unit; the gap is a flat 72, which is what 1440 has today — so
   1440 is unchanged to the pixel, every width below it holds that same 540
   because nothing in the sum moves there, and above it the picture grows
   with the nav rather than away from it, keeping the same 72.

   The picture inside still covers — see `.pb-hero--filled` — so a wider
   window crops rather than stretches. */
.pb-hero {
  --nav-foot: calc(21px + 447 * var(--snav-u));
  --hero-drop: 72px;
  height: calc(var(--nav-foot) + var(--hero-drop));
  background: var(--placeholder);
}

/* And it runs to the edges of the screen rather than standing inside the
   page's gutter. Only the hero: everything under it keeps the measure.

   The page is inset twice over — it is nudged by `--o` and then padded by
   20 — so reaching the top and the left means giving both of them back. The
   right edge of the page is already the right edge of the screen, so that
   side owes only the padding. `width: auto` because the block otherwise
   holds the measure it is trying to escape, and a stretched flex item with
   negative margins takes the room they open for it. */
.pb-hero {
  --hero-bleed: calc(var(--o) + 20 * var(--u));
  width: auto;
  margin:
    calc(-1 * var(--hero-bleed))
    calc(-20 * var(--u))
    0
    calc(-1 * var(--hero-bleed));
}

/* --- Full-width assets --------------------------------------------------
   One to three pictures running the page's full measure, in the arrangements
   the frame draws: one at 16:9 (592:42990), a pair as squares (592:43019),
   three portraits (592:43022). A pair can also be asked for at 16:9, which
   is the one arrangement the count alone cannot name.

   The row does the arranging and each picture keeps every rule it had when
   it was the block: the ground it is given, how it is framed, how it is
   revealed. Only its shape now comes from the row it is in.
------------------------------------------------------------------------- */
.pb-full {
  display: grid;
  gap: calc(4 * var(--u));
  grid-template-columns: 1fr;
}

.pb-full--two,
.pb-full--twoWide { grid-template-columns: repeat(2, 1fr); }
.pb-full--three { grid-template-columns: repeat(3, 1fr); }

.pb-media { background: var(--placeholder); }

/* Deliberately blank, rather than waiting to be filled: it holds its column
   and the page shows through. A colour set on it still counts — the rule
   naming `--media-bg` further down is the more specific of the two — so the
   same slot can be a panel of colour instead. */
.pb-media--empty { background: none; }

.pb-full--one .pb-media { aspect-ratio: 1400 / 788; }
.pb-full--two .pb-media { aspect-ratio: 1; }
.pb-full--twoWide .pb-media { aspect-ratio: 698 / 392.625; }
.pb-full--three .pb-media { aspect-ratio: 464 / 618.667; }

/* --- Overview ------------------------------------------------------------ */

.pb-overview {
  display: flex;
  align-items: flex-start;
}

.pov-identity {
  flex: 0 0 auto;
  display: flex;
  /* No gutter: the frame runs the title's 705 straight into the scope's 223. */
  gap: 0;
  /* The title takes what the 223 scope beside it leaves (401:27546). */
  width: calc(928 * var(--u));
  justify-content: space-between;
  padding-top: calc(14 * var(--u));
  border-top: 0.5px solid var(--rule);
}

.pov-name {
  flex: 1 1 0;
  min-width: 0;
  width: auto;
  margin: 0;
  font-family: var(--serif);
  font-size: var(--fs-title);
  font-weight: 400;
  line-height: var(--lh-serif);
  color: var(--ink-copy);
  text-box: trim-both cap alphabetic;
}

/* A logo stands in for the name at the same optical size: 0.72em is the cap
   height of the serif the name would have been set in, so the two versions of
   the page sit on the same line. Height leads and width follows, with
   `contain` so a very wide lockup is scaled down rather than squashed. The
   cap-trim is switched off — there is no text left to trim. */
.pov-name--logo { text-box: normal; display: flex; align-items: flex-start; }

.pov-name__frame { display: block; max-width: 100%; }

.pov-name__frame img {
  display: block;
  height: 0.72em;
  width: auto;
  max-width: 100%;
  object-fit: contain;
  object-position: left center;
}

/* A wordmark wiping up reads better than one zooming: the scale that suits a
   photograph makes a logo look like it is being pushed at the reader. */
.pov-name__frame[data-reveal-media] { --rm-zoom: 1.02; --rm-zoom-ms: 680ms; }

.pov-scope {
  display: flex;
  flex-direction: column;
  gap: calc(20 * var(--u));
  width: calc(223 * var(--u));
  padding-top: calc(6 * var(--u));
}

/* The wide frame lets the scope speak for itself; the narrow one labels it,
   and puts the label back in the rail. */
.pov-scope > .label { display: none; }

.pov-scope .label { width: calc(98 * var(--u)); }

.pov-about {
  flex: 1 1 auto;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  flex: 0 0 calc(460 * var(--u));
  margin-left: calc(12 * var(--u)); /* identity ends at 928, About opens at 940 */
  padding-top: calc(20 * var(--u));
  border-top: 0.5px solid var(--rule);
}

.pov-about .label { flex: 1 1 0; min-width: 0; }
.pov-about__body { flex: 0 0 auto; width: calc(342 * var(--u)); }

/* --- A section (410:29518) -----------------------------------------------
   The words take the left half and never move; the pictures stack down the
   right in whatever widths they were given. 696 and 694 either side of a 10
   gutter is the frame's 1400.
------------------------------------------------------------------------- */

.pb-sec {
  position: relative;
  display: flex;
  align-items: flex-start;
  gap: calc(10 * var(--u));
}

/* The rule the frame draws over the words (410:29457), the width of the copy
   column alone. It sits on the section rather than on the copy because in a
   gallery the copy is stuck to the window, and the rule belongs at the top of
   the section, not travelling down it. */
.pb-sec::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  width: calc(696 * var(--u));
  height: 0;
  border-top: 0.5px solid var(--rule);
}

/* The copy hangs from a fixed drop rather than centring on the pictures, so
   a section with one picture and a section with six open on the same line. */
.sec-copy {
  flex: 0 0 calc(696 * var(--u));
  display: flex;
  align-items: flex-start;
  gap: calc(10 * var(--u));
  /* A margin rather than padding: in a gallery this box is stuck to the
     window, and padding would stick 160 of empty space along with it. */
  margin-top: calc(160 * var(--u));
}

/* A gallery holds the words beside the pictures while they go past. 88 clears
   the folded nav, which is 48 tall on the same 20 the copy starts on. */
.pb-sec--gallery .sec-copy {
  position: sticky;
  top: calc(88 * var(--u));
}

.sec-copy__label { flex: 0 0 calc(225 * var(--u)); }
.sec-copy__body { flex: 0 0 calc(343 * var(--u)); }

.sec-media {
  flex: 0 0 calc(694 * var(--u));
  display: flex;
  flex-direction: column;
  gap: calc(4 * var(--u));
}

.sec-row {
  display: flex;
  gap: calc(4 * var(--u));
}

/* Every picture fills its share of the row, so a half is half of whatever
   the column is at this width rather than a number that has to be kept in
   step with it. The shape is the slot's; the hotspot decides the crop. */
.sec-pic {
  flex: 1 1 0;
  min-width: 0;
  background: var(--placeholder);
  overflow: hidden;
}

/* --- A chosen background, and sitting inside the frame -------------------
   A frame takes the page grey unless a colour was picked for it. `--media-bg`
   is set on the frame itself, so it also stands behind a picture with
   transparency and behind one that does not fill its slot.

   Sitting inside means the whole thing is shown rather than cropped: the
   asset is centred, held to `--media-scale` of the frame, and `contain` keeps
   its own proportions inside that. Useful for a logo or a screen, where a
   crop would cut something that matters.
------------------------------------------------------------------------- */
.pb-hero[style*="--media-bg"],
.pb-media[style*="--media-bg"],
.sec-pic[style*="--media-bg"] { background: var(--media-bg); }

/* Placed rather than centred. A percentage height against a frame whose own
   height comes from `aspect-ratio` does not resolve reliably when the asset is
   an ordinary in-flow child — it fell back to the asset's own height and hung
   out of the bottom of the frame. Taking it out of flow gives the percentages
   a definite box to resolve against, and left/top with a matching negative
   translate means 0% puts its edge at the frame's edge and 50% centres it, so
   it can be moved anywhere rather than only sideways. */
.media--inside { position: relative; }

/* Named on both sides so this outweighs the plain `.sec-pic img` and
   `.pb-media--filled img` rules further down, which would otherwise win on
   source order and put the crop back. */
.pb-hero.media--inside > img,
.pb-hero.media--inside > video,
.pb-media.media--inside > img,
.pb-media.media--inside > video,
.sec-pic.media--inside > img,
.sec-pic.media--inside > video {
  position: absolute;
  left: var(--media-x, 50%);
  top: var(--media-y, 50%);
  translate: calc(-1 * var(--media-x, 50%)) calc(-1 * var(--media-y, 50%));
  width: var(--media-scale, 100%);
  height: var(--media-scale, 100%);
  object-fit: contain;
}

/* The reveal zooms a picture as it arrives, which would push a fitted one
   past the edges it was chosen to stay inside. */
.js .media--inside > img,
.js .media--inside > video { transform: none; }

/* Crop on a video: a file cannot be cut server-side the way a picture can, so
   the magnification happens here instead. */
.pb-media[style*="--media-zoom"] > video,
.sec-pic[style*="--media-zoom"] > video { transform: scale(var(--media-zoom)); }

.sec-pic--wide { aspect-ratio: 694 / 520.5; }
.sec-pic--portrait { aspect-ratio: 3 / 4; }
.sec-pic--square { aspect-ratio: 1; }
.sec-pic--landscape { aspect-ratio: 4 / 3; }

/* A half with nothing to pair with keeps its half and leaves the rest empty.
   Half of the row rather than the frame's 345, so it is still half once the
   column narrows. */
.sec-row > .sec-pic--portrait:only-child,
.sec-row > .sec-pic--square:only-child,
.sec-row > .sec-pic--landscape:only-child { flex: 0 0 calc(50% - 2 * var(--u)); }

.sec-pic img,
.sec-pic video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: var(--media-pos, 50% 50%);
}

/* --- Wide text block ------------------------------------------------------ */

.pb-text {
  display: flex;
  align-items: flex-start;
  padding: calc(20 * var(--u)) 0 calc(120 * var(--u));
}

.pt-eyebrow { flex: 0 0 auto; width: calc(470 * var(--u)); }

.pt-cols {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: calc(40 * var(--u));
}

.pt-cols .body { width: calc(812 * var(--u)); }

/* --- Template on narrow screens (365:19141) ------------------------------
   The narrow frame hangs the whole page off a single measure: a 118 rail
   carrying the labels, a 10 gutter, and the copy column beside it. Every
   image but the opening one lines up with that copy column rather than with
   the page, so the labels are the only thing on the left edge the whole way
   down and the images step in from the right.

   It also reads in a different order from the wide frame — title and scope
   come before the opening image, About after it — so `.pb-overview` is
   dissolved and its two halves take their own places in the stack.
------------------------------------------------------------------------- */

@media (max-width: 1023px) {

  /* 118 + 10 + 245 is the frame's 373. The copy column is what is left, so
     it holds its 245 at 393 and grows with anything wider. */
  .page--project {
    --rail: 118px;
    --rail-gap: 10px;
    padding: 410px var(--pad) 0;
  }

  /* The wide frame's 160 between blocks is carried on the blocks themselves;
     down here the 40 gap is the whole rhythm. */
  .pstack { --stack-gap: 40px; }

  .pb-sec,
  .pb-full,
  .pb-overview + .pb-sec,
  .pb-overview + .pb-full { margin-top: 0; }

  /* 365:19144 title and scope, 365:19150 the opening image, 365:19151 About. */
  .pb-overview { display: contents; }
  .pov-identity { order: -2; }
  .pb-hero { order: -1; }

  /* 365:19144. One rule over the whole thing, and the name under it with the
     scope beside it — the wide frame's reading at this width rather than a
     different one. The name had been standing above the rule with the scope
     stacked under it as a labelled row of its own, which read as two
     unrelated things rather than as a name and what it was for. */
  .pov-identity {
    flex-direction: row;
    align-items: flex-start;
    justify-content: space-between;
    gap: 0;
    width: auto;
    padding-top: 20.5px;
    border-top: 0.5px solid var(--rule);
  }

  .pov-name {
    flex: 1 1 0;
    min-width: 0;
    width: auto;
    font-size: 40px;
  }

  /* Every row on the page is the same three things: label, gutter, copy. */
  .pov-about,
  .sec-copy,
  .pb-text,
  .pcredits {
    display: flex;
    flex-direction: row;
    align-items: flex-start;
    gap: var(--rail-gap);
    width: auto;
  }

  .pov-about > .label,
  .sec-copy__label,
  .pt-eyebrow,
  .pcredits__label {
    flex: 0 0 var(--rail);
    width: var(--rail);
  }

  .pov-about__body,
  .sec-copy__body,
  .pt-cols,
  .pt-cols .body,
  .pcredits__list {
    flex: 1 1 0;
    min-width: 0;
    width: auto;
  }

  /* The last quarter of the row, which is where the wide frame puts it too —
     the title's 705 and the scope's 223 of 928 is the same three quarters.

     No eyebrow. Standing beside the name there is nothing else the words
     could be, and the label was only ever here because the scope used to sit
     on a row of its own where they would have been unaccounted for. Both the
     name and the copy are cap-trimmed, so their boxes starting level is
     their first lines starting level and neither needs nudging to the
     other. */
  .pov-scope {
    display: block;
    flex: 0 0 25%;
    width: 25%;
    padding-top: 0;
    border-top: 0;
  }

  .pov-about {
    flex: 0 0 auto;
    margin-left: 0;
    padding: 20.5px 0 40px;
    border-top: 0.5px solid var(--rule);
  }

  /* The copy keeps the full measure whatever the pictures beside it do, and
     nothing is stuck to the window — the words sit above the pictures here,
     so there is nothing for them to stay beside. */
  .pb-sec::before { display: none; }

  .sec-copy,
  .pb-sec--gallery .sec-copy {
    position: static;
    flex: 0 0 auto;
    align-self: stretch;
    margin-top: 0;
    padding: 20px 0 40px;
    border-top: 0.5px solid var(--rule);
  }

  .pb-text { padding: 20px 0 40px; }
  .pt-cols { align-items: stretch; gap: 28px; }

  .pcredits { margin-top: 0; padding: 20px 0 40px; }

  /* The opening image is the one thing that runs edge to edge (365:19150) —
     which this frame has always done; it is the wide one that has just
     caught up. The top is this frame's own, though: the gutter the wide
     frame gives back up there is the room this one stands the name in. */
  .pb-hero {
    width: auto;
    height: auto;
    margin-top: 0;
    margin-inline: calc(-1 * var(--pad));
    aspect-ratio: 393 / 524;
  }

  /* One across the page keeps the narrow frame's own shape (365:19159). The
     other arrangements hold their rows: a triptych read as a stack is not a
     triptych. */
  /* No nav down the side at this width, so the door keeps the page's own
     measure, and the row becomes a column: the name, then what it asks. */
  .pblock.plock {
    flex-direction: column;
    margin-left: 0;
    padding-bottom: 120px;
  }

  /* The row's 811 is a width up there and would be a height down here, the
     column having turned the axis: a flex-basis is along the axis, whichever
     one that is. */
  .plock__ask {
    flex: 0 0 auto;
    width: 100%;
    margin-left: 0;
    margin-top: 30px;
  }
  .plock__label { flex: 0 0 100%; padding-top: 0; padding-bottom: 10px; }
  .plock__field { flex: 1 1 auto; height: 34px; }
  .plock__go { flex: 0 0 110px; height: 34px; margin-left: 10px; }
  /* The column's own width, not the wide frame's 694, which down here is 694
     actual pixels and runs straight out of the page's gutter. */
  .plock__wrong { flex: 0 0 100%; margin-left: 0; }

  .pb-full--one .pb-media { aspect-ratio: 373 / 280; }

  /* A section stacks: the words first, then the pictures under them, each
     sitting on the copy column. The halves stay halves of that column, so a
     pair still reads as a pair rather than as two full-width pictures. */
  .pb-sec {
    flex-direction: column;
    align-items: flex-end;
    gap: 10px;
  }

  .sec-media {
    flex: 0 0 auto;
    width: calc(100% - var(--rail) - var(--rail-gap));
  }

}

/* A project added in the Studio before its artwork — same grey the Figma
   template uses for a placeholder. */
.card__img--empty { background: var(--placeholder); }

/* Blocks holding a real image rather than the grey placeholder. A section's
   pictures carry their own rules; these two are the ones that stand alone. */
.pb-hero--filled,
.pb-media--filled { background: none; }

.pb-hero--filled img,
.pb-hero--filled video,
.pb-media--filled img,
.pb-media--filled video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: var(--media-pos, 50% 50%);
}

/* ==========================================================================
   Post Scriptum — Figma 401:27722 (1440 x 8217)

   The frame's scatter is a twelve-column grid; which card lands in which slot
   is worked out at build time in src/lib/ps-layout.ts, which writes the four
   numbers below onto each figure. Nothing here knows about any particular
   exploration — it only knows how to draw a slot.
   ========================================================================== */

/* The one dark page. #161616 under everything, white over it — sampled off
   the frame, where the band at the foot stays a flat black against it. */
:root { --ps-ground: #161616; }

/* The ground goes on the document, not on `.page`: that box is inset by the
   gutter and only as tall as its content, so painting it there would leave a
   pale margin down the sides and under the footer. */
html:has(.page--ps),
body:has(.page--ps) { background: var(--ps-ground); }

.page--ps {
  --rule: rgba(255, 255, 255, 0.2);
  /* A frame holds the ground until what goes in it has decoded, and the
     site's placeholder is a light grey — which is right on a white page and
     the most conspicuous thing on this one. A video only has to have reached
     its metadata to be let in, not its first frame, so the wait is real. The
     token rather than the one selector: anything else on this page that
     stands in for a picture wants the same answer. */
  --placeholder: var(--ink);
  overflow: visible;
  padding: calc(20 * var(--u)) calc(20 * var(--u)) 0;
  background: var(--ps-ground);
  color: var(--paper);
}

/* Only the page's own copy — the band at the foot already knows what it is,
   and its byline is deliberately white at a half. */
.page--ps .psbody .label,
.page--ps .psbody .body { color: var(--paper); }

/* The nav reverses rather than being redrawn: every mark in it is a
   single-colour black SVG, so inverting is exactly the light copy, and the
   menu icon is drawn from `currentColor` already. */
.page--ps .snav,
.page--ps .snav .label { color: var(--paper); }

/* The control sets its own colour rather than taking the nav's, so the page
   has to reverse it by hand or the icon stays black on the black ground. */
.page--ps .snav__open { color: var(--paper); }

/* The mark is no longer among these: it inverts against the page itself, so
   reversing it here as well would cancel that out. */
.page--ps .snav__wordmark,
.page--ps .snav__step img { filter: invert(1); }

/* Open, the panel and the rows sit a step above the ground: the same idea as
   the light pages, turned over. #2d2d2d is exactly what white at a tenth
   drew over this ground, named rather than mixed, so nothing passing beneath
   the nav can show through it.

   The lit row goes the whole way to white, and its word turns over to match.
   On a black page that is the same gesture as black on a white one. */
.page--ps {
  --nav-panel-on-light: #2d2d2d;
  --nav-item-hover: var(--paper);
  --nav-item-hover-ink: var(--ink);
}

/* One column of rows. The grid is here for the rows alone — two cards that
   share one are what lets a pair hang from a common baseline — while across
   the measure each card is placed at the frame's own offset rather than on
   any column of a grid. */
.psbody {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  /* Not stretch: a paired card would be pulled to the height of the row and
     lose its shape. Each one keeps its own, and the short half of a pair is
     dropped to the baseline instead. */
  align-items: start;
  justify-items: start;
}

/* --- The archive (892:52393) ---------------------------------------------
   Everything on the page at one width, five across. Each picture keeps its
   own proportion — the frame draws a 225 column and lets the heights fall
   where they fall — with its number over it and what it is underneath, and
   the row is as tall as the tallest thing in it.

   It stands in the same column the opening does, which is where the nav
   ends, and it is one of the two things this page can be: see `.pscard`
   below for the other, and the panel's minus and plus for which.
------------------------------------------------------------------------- */

.psgrid {
  display: none;
  grid-row: 2;
  margin: calc(84.5 * var(--u)) 0 0 calc(235 * var(--snav-u));
  width: calc(var(--content) - 235 * var(--snav-u));
  grid-template-columns: repeat(5, minmax(0, 1fr));
  align-items: start;
  column-gap: calc(10 * var(--u));
  row-gap: calc(64 * var(--u));
}

.page--ps.is-small .psgrid { display: grid; }

/* One reading travels into the other — see the handler in app.js.

   Every picture carries the name of the exploration it holds, the same name
   in both readings, so the browser recognises them as one thing in two
   places and moves each from where it was to where it is going rather than
   dissolving one into the other. The name is only worn while the swap is
   running: it is a name in a snapshot, and a page navigation uses the very
   same machinery.

   Ours is the clock and the curve; the travel itself is the browser's. */
/* Only what the reader can actually see travels — `.is-near` is put on by
   app.js, on the pictures that are in the viewport at the moment the swap
   begins and on their counterparts in the other reading.

   Naming all nineteen meant nineteen groups, and the ones off the bottom of
   a very tall page were given journeys of several thousand pixels to make in
   half a second. What crosses the screen at that speed is not a picture
   arriving, it is debris — and it is most of what made the change feel
   jumpy. What is not named simply changes with the page. */
.ps-swapping .is-near .pscard__img,
.ps-swapping .is-near .psitem__img { view-transition-name: var(--vtn); }

/* Long enough to be followed, now that there is something to follow. */
.ps-swapping::view-transition-group(*) {
  animation-duration: 440ms;
  animation-timing-function: var(--ease-glide);
}

/* But not the page itself. The two readings are nothing like the same height
   — a composed page runs to several screens and the archive does not — and a
   root group left to animate interpolates between those two sizes, which
   stretches the whole snapshot on its way and is the lurch underneath
   everything else. It holds still; only what is in it crosses. */
.ps-swapping::view-transition-group(root) { animation: none; }

.ps-swapping::view-transition-old(root),
.ps-swapping::view-transition-new(root) {
  animation-duration: 360ms;
  animation-timing-function: var(--ease-glide);
}

/* One or the other, never both. */
.page--ps.is-small .pscard { display: none; }

.psitem { display: block; margin: 0; width: 100%; }

/* 17 over the picture and 10 under it, which is the number's own 7 and the
   10 the frame leaves either side of it.

   The number is the picture's place in its row, not in the archive, so it
   starts again at every row — and which of the two is right depends on how
   many there are to a row, which is this file's business and not the
   markup's. Both are written on the element and the frame takes the one it
   wants. */
.psitem__no {
  display: block;
  margin-bottom: calc(10 * var(--u));
}

.psitem__no::before { content: attr(data-of-five); }

.psitem__img {
  aspect-ratio: var(--ar, 4 / 3);
  overflow: hidden;
  background: var(--placeholder);
}

.psitem__img img,
.psitem__img video { display: block; width: 100%; height: 100%; object-fit: cover; }

.psitem__cap { display: block; margin-top: calc(10 * var(--u)); }

.psitem__cap:empty { margin-top: 0; }

/* --- Opening (401:27734) — beside the nav, on the same measure as the cards */

/* The rule starts where the words do.

   It ran the page's measure for a while, and the page's measure begins at 20,
   which is exactly where the nav begins — so its first 226 were behind the
   panel and it looked like a line interrupted. Drawn over the nav it kept its
   gutter but crossed the panel, which is a line where the frame does not draw
   one. Home and About settle it: both begin their rule where the row begins,
   clear of the nav, and run it out to the frame's right edge. This row's first
   word stands there too, so the rule starts under it.

   Off the nav, not off the frame: the panel stops shrinking at 226 while the
   frame goes on scaling, so 235 of the frame's own units walks in under it
   somewhere below 1440 and the rule would end up behind the panel again. */
.psintro {
  grid-row: 1;
  margin-left: calc(235 * var(--snav-u));
  width: calc(var(--content) - 235 * var(--snav-u));
  display: flex;
  align-items: flex-start;
  padding-top: var(--rule-gap);
  border-top: 0.5px solid var(--rule);
}

/* 752:45647 — three things on the row now rather than two: when the page was
   last touched, what the page is, and what it is for.

   The stamp's column is the distance between where the row starts and where
   the frame's own second column does, which is 588 — the same figure the home
   page's Define row indents by, and for the same reason. It has to give back
   what the panel takes: the row begins at the nav's edge, which stops
   shrinking at 226 while the frame goes on scaling, so measuring the column
   off the frame alone would walk the eyebrow leftward under the panel. */
.psintro__stamp { flex: 0 0 calc(588 * var(--u) - 235 * var(--snav-u)); }

/* A ten against a fourteen: the label is dropped half the difference so it
   sits on the paragraph's first line rather than above it. */
.psintro__label {
  flex: 0 0 calc(118 * var(--u));
  padding-top: var(--label-nudge);
}

/* Hard to the right edge — the frame hangs the paragraph off the measure's
   end, not off the eyebrow, so the space between the two opens as the page
   widens rather than the copy drifting away from the margin. */
.psintro__body {
  flex: 0 0 calc(459 * var(--u));
  margin-left: auto;
}

/* --- A card (401:27724) -------------------------------------------------- */

.pscard {
  grid-row: var(--row);
  /* The frame's coordinates, scaled. Placing by offset rather than by column
     keeps the 224 portrait at 224 instead of rounding it out to a twelfth. */
  margin-left: calc(var(--x) * var(--u));
  width: calc(var(--w) * var(--u));
  display: flex;
  flex-direction: column;
  gap: calc(10 * var(--u));
  /* The air the frame leaves above this row. Both halves of a pair carry it;
     on the one that hangs from the baseline it simply has nothing to do. */
  margin-top: calc(var(--gap) * var(--u));
}

/* Pairs hang from a shared baseline rather than a shared top edge — the
   695 square and the 224 portrait beside it both end on 1407 (401:27725). */
.pscard--baseline { align-self: end; }

.pscard__img {
  aspect-ratio: var(--ratio);
  overflow: hidden;
  background: var(--placeholder);
}

.pscard__img img,
.pscard__img video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.psempty {
  grid-row: 2;
  margin-left: calc(472 * var(--u));
  margin: calc(100.5 * var(--u)) 0 0;
}

/* --- Post Scriptum on narrow screens (404:27818) -------------------------
   The narrow frame drops the scatter altogether: one column, the full
   measure, and an even 50 between every card. What carries over from the
   wide composition is the shape each exploration was given — the frame runs
   the same four, 16:9, 1:1, 3:4 and 4:3, straight down the page — so the
   slot still decides how a picture is cropped, just not where it sits.
------------------------------------------------------------------------- */

@media (max-width: 1023px) {

  /* The rule on the intro opens on the same line as home and Work. */
  .page--ps { padding: var(--ntop) var(--pad) 0; }

  .psbody { display: flex; flex-direction: column; gap: 50px; }

  .psintro { padding: 20px 0 0; }

  /* The opening reads as it does on home and About: the stamp up in the band
     the bar stands in, then a ruled row of the eyebrow and the copy. It had
     been taking a line of its own inside the row, which put two things above
     the writing where every other page has one. Same figures as `.mstamp`
     and `.hrow--define`, because it is the same opening. */
  .psintro__stamp {
    position: absolute;
    top: calc(var(--ntop) - 30px);
    right: var(--pad);
    flex: none;
    margin: 0;
    text-align: right;
  }

  .psintro {
    justify-content: space-between;
    gap: var(--gutter);
    padding-top: 20.75px;
  }

  .psintro__label { flex: 0 0 auto; padding-top: 0; }
  .psintro__body { flex: 0 0 73.9%; min-width: 0; margin-left: 0; }

  /* No lanes, no baselines, no rhythm of gaps: every card is the measure. */
  .pscard { width: 100%; margin: 0; align-self: auto; }
  .psintro { margin-left: 0; width: auto; }

  /* All of the measure. There is no eyebrow beside this grid and no panel to
     leave room for, so there is nothing to indent past — and four across
     rather than five, which is as many as this width will carry and still
     show what is in them. */
  .psgrid {
    margin: 0;
    width: auto;
    grid-template-columns: repeat(4, minmax(0, 1fr));
    column-gap: 10px;
    row-gap: 32px;
  }

  /* Four to a row down here, so the count starts again on the fourth. */
  .psitem__no::before { content: attr(data-of-four); }

  .psempty { margin: 0; }
}

/* --- The lightbox --------------------------------------------------------
   The reel's own way of opening out, made into a thing the whole site can
   use. The picture takes the window less a gutter on every side, the
   wordmark sits at the top left of it and the way out at the top right, and
   between them now are the two marks that walk a gallery.

   Nothing here fades. A lightbox is not a transition between two states of a
   page, it is a picture being looked at: it is there or it is not, and the
   next one replaces this one. Anything else puts a beat between the reader
   and the thing they asked to see, nineteen times over.
------------------------------------------------------------------------- */

.lbox {
  position: fixed;
  inset: 0;
  /* Same trap as the other two fixed overlays: a fixed box's `100%` is the
     viewport and its scrollbar both. See the note on `--content`. */
  width: var(--vw, auto);
  z-index: 300;
  display: none;
}

.lbox.is-open { display: block; }

/* The gutter, which is all of the page that is left showing. */
.lbox__scrim { position: absolute; inset: 0; }

/* A gutter clear of every edge — and clear of the notch and the home
   indicator besides, which the reel never had to think about because it only
   ever opened on a frame wide enough to have none. */
.lbox__frame {
  position: absolute;
  inset:
    max(var(--pad), env(safe-area-inset-top, 0px))
    max(var(--pad), env(safe-area-inset-right, 0px))
    max(var(--pad), env(safe-area-inset-bottom, 0px))
    max(var(--pad), env(safe-area-inset-left, 0px));
  background: var(--ink);
  overflow: hidden;
  outline: none;
}

/* Named for a screen reader and drawn for nobody: the frame already says
   what it is, and the words would be a second caption over the picture. */
.lbox__title {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.lbox__stage { position: absolute; inset: 0; }

.lbox__stage img,
.lbox__stage video {
  display: block;
  width: 100%;
  height: 100%;
}

/* The reel covered its frame and always will: it is a film shot for this and
   there is nothing in the corners to lose. An exploration is the work, and a
   lightbox that crops the work is hiding the thing it was opened to show —
   so those are shown whole, on the ground the frame already carries. Which
   of the two is the item's own to say; see `data-lb-fit`. */
.lbox__stage img { object-fit: contain; }
.lbox__stage video { object-fit: contain; }
.lbox.is-cover .lbox__stage img,
.lbox.is-cover .lbox__stage video { object-fit: cover; }

/* 408:28719 — the reel's logo row, which is where the way out has always
   been on this. Over the picture rather than above it.

   Drawn white and differenced against whatever it is standing on, the way
   the narrow nav's bar is: black over a pale exploration, white over a dark
   one, and legible over a photograph that is both at once — without the row
   having to be told which, and without a scrim laid over the work to make
   room for it. The one thing a lightbox must not do is hide the picture.

   The blend wants a backdrop inside the same stacking context, and it has
   one: `.lbox` makes the context, the frame does not, so what is under this
   is the frame's ground and the picture lying on it. */
.lbox__bar {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  width: 100%;
  padding: calc(14 * var(--u)) calc(20 * var(--u)) calc(14 * var(--u)) calc(20 * var(--u));
  color: var(--paper);
  mix-blend-mode: difference;
}

/* Shown only where it is asked for — see `is-marked` in app.js. */
.lbox__mark { display: none; width: calc(118 * var(--u)); height: auto; }
.lbox.is-marked .lbox__mark { display: block; }

/* The row's own white, not the mono's ink. `.label` names a colour of its
   own, and a black label differenced against a pale picture comes out white
   on white — which is the count gone exactly where it is most wanted. Said
   here rather than inherited, since inheriting is what failed.

   Full strength too, and quieter by being smaller rather than fainter: a
   partly transparent white differenced against a mid-tone comes out a grey
   close to the thing behind it, which is the one legibility the blend cannot
   recover. */
.lbox__count { color: var(--paper); opacity: 1; }
.lbox.is-marked .lbox__count { margin-left: calc(20 * var(--u)); }

/* The way through and the way out, together at the end of the row. */
.lbox__controls {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: calc(8 * var(--u));
}

.lbox__step {
  position: relative;
  width: calc(32 * var(--u));
  height: calc(32 * var(--u));
  padding: 0;
  border: 0;
  background: none;
  color: var(--paper);
  cursor: pointer;
}

/* The site's own mark, pointing the way it goes. */
.lbox__step::before {
  content: "";
  position: absolute;
  top: 50%;
  left: 50%;
  width: calc(16 * var(--u));
  height: calc(16 * var(--u));
  transform: translate(-50%, -50%);
  background: currentColor;
  -webkit-mask: url("/assets/svg/section-arrow.svg") no-repeat center /
    calc(9 * var(--u));
  mask: url("/assets/svg/section-arrow.svg") no-repeat center /
    calc(9 * var(--u));
}

.lbox__step--prev::before { transform: translate(-50%, -50%) rotate(180deg); }

@media (hover: hover) and (min-width: 1024px) {
  .lbox__step { opacity: 0.75; transition: opacity 120ms var(--ease-soft); }
  .lbox__step:hover { opacity: 1; }
}

/* One picture is not a gallery. The pair comes out of the layout rather than
   standing there refusing to answer. */
.lbox.is-one .lbox__step { display: none; }

.lbox__shut {
  margin-left: calc(12 * var(--u));
  padding: 0;
  border: 0;
  background: none;
  color: var(--paper);
  cursor: pointer;
}

.lbox__step:focus-visible,
.lbox__shut:focus-visible { outline: 1px solid var(--paper); outline-offset: 4px; }

/* Under the picture's own foot, and differenced for the same reason the row
   above it is. */
.lbox__caption {
  position: absolute;
  left: calc(20 * var(--u));
  right: calc(20 * var(--u));
  bottom: calc(14 * var(--u));
  z-index: 2;
  margin: 0;
  color: var(--paper);
  mix-blend-mode: difference;
}

.lbox__caption:empty { display: none; }

/* A grid item is a control now, and says so when it is tabbed to. */
.psitem[data-lb] { cursor: pointer; }
.psitem[data-lb]:focus-visible { outline: 1px solid var(--paper); outline-offset: 6px; }

@media (max-width: 1023px) {
  /* A thumb's worth of target, which is more than the frame's own 32 scaled
     to this width would give it. */
  .lbox__bar { padding: 10px; }
  .lbox__mark { width: 100px; }
  .lbox.is-marked .lbox__count { margin-left: 12px; }
  .lbox__controls { gap: 4px; }
  .lbox__step { width: 44px; height: 44px; }
  .lbox__shut { margin-left: 8px; }
  .lbox__caption { left: 10px; right: 10px; bottom: 10px; }
}

/* --- A page that plays no arrival ----------------------------------------
   The other half of `data-reveal-none`; see `revealTargets` in app.js for
   the first. That one stops the run from ever collecting these elements —
   nothing is split, timed, clipped or dealt a delay. This one takes off the
   states they are held in before the run would have reached them, which is
   the part that matters for a flash: the mask and the hidden visibility are
   written by the stylesheet the moment `.js` lands on the document, long
   before any script has decided what to do with them. Left on, with nothing
   coming to lift them, the page would simply never appear.

   `!important` because these are being switched off rather than restyled,
   and the rules they are switching off run from two attributes deep to a
   class and a `:not()` besides — the media frame's `opacity: 0` while it
   waits for its file is the highest of them, and the most important one to
   beat: it is the loading state, and on a page with no arrival there is
   nothing to load into. The picture arrives as the browser decodes it.
------------------------------------------------------------------------- */

[data-reveal-none] [data-reveal],
[data-reveal-none] [data-reveal-rows],
[data-reveal-none] [data-reveal-rows] > *,
[data-reveal-none] [data-reveal-media],
[data-reveal-none] [data-reveal-media] > img,
[data-reveal-none] [data-reveal-media] > video,
[data-reveal-none] [data-reveal-rule] {
  visibility: visible !important;
  clip-path: none !important;
  opacity: 1 !important;
  transform: none !important;
  transition: none !important;
}

/* A rule is drawn by its own background rather than by a mask, so it is
   turned on rather than uncovered. */
[data-reveal-none] [data-reveal-rule] {
  background-size: 100% var(--rule-w, 0.5px) !important;
}

/* --- The strip the status bar stands in ----------------------------------
   The page is the whole screen now — that is what `viewport-fit=cover` buys,
   and it is the only way the black drawer reaches the top and the bottom
   instead of leaving a band of paper at each end. What it costs is that the
   page scrolls under the status bar as well, so a line of copy rides up into
   it and sits there above the bar: body text over the clock, with the mark
   and the menu below it. Which reads as something broken rather than as
   something full-bleed.

   So the ground keeps the whole screen and the reading does not. The inset
   is padding rather than margin, which is the whole of it: the page's own
   colour still fills the strip, and only what is set on it starts below.
   Added to what each page already holds its top at rather than replacing it,
   since that figure is how each one clears the fixed bar.

   Nothing on a phone without a notch, and nothing at all above the fold —
   `env()` is zero in both cases, which is why this can be stated flatly.
------------------------------------------------------------------------- */

@media (max-width: 1023px) {
  .page--home,
  .page--about { padding-top: env(safe-area-inset-top, 0px); }

  .page--work,
  .page--ps { padding-top: calc(var(--ntop) + env(safe-area-inset-top, 0px)); }

  .page--project { padding-top: calc(410px + env(safe-area-inset-top, 0px)); }
}
