/* Strange Practice — design system */

/* NO WEBFONTS. The site sets in Georgia and the platform mono — see the one
   type rule on body — so nothing here loads a typeface, and the fastest font is
   the one already on the machine.

   Two @font-face blocks for Helvetica Now Text lived here, and 322 KB of files
   under public/static/fonts backed them. Neither face had been referenced by a
   rule since the switch to Georgia, and one of them was still in a
   <link rel="preload"> in app/layout.tsx — so every visitor was paying 47 KB at
   the highest fetch priority the browser has for a typeface no glyph on the page
   was ever set in. Both blocks, the preload and the files are gone together;
   git has them if a webfont ever comes back. */


:root {
  --c-paper: #ffffff;
  --c-ink: #111111;
  /* NO GREY TEXT. This was 60% black and is the ink itself now. Kept as a
     token rather than replaced at all fifteen call sites, so the distinction
     between "body" and "secondary" survives in the stylesheet even though the
     two currently paint the same — and so bringing a softer tier back is one
     line. Rules and hairlines are untouched: --c-rule and --c-line are not
     text. */
  --c-ink-soft: var(--c-ink);
  --c-smoke: #e9e9e9;
  --c-rule: rgba(0, 0, 0, 0.12); /* 12% opacity black divider lines */

  --hover-fade: 0.7;   /* the one de-emphasis left, now that nothing is grey */

  --p-red: #e0331e;
  --p-green: #1f9e4c;
  --p-blue: #1c3fd6;

  /* TWO VOICES. Georgia for anything that is writing, a sans for anything that
     is apparatus — the mark, the meta, the counters.

     Named for the job, not the classification. It was --font-mono, which stopped
     being true the moment the stack changed and would have gone on telling line
     44's reader something false. Whatever this is set in, it is what the labels
     use.

     system-ui first, so the page is set in the machine's own interface face: SF
     on a Mac, Segoe on Windows, Roboto on Android. That is the same argument
     ui-monospace was here for — the platform's native cut, already resident, no
     bytes over the wire. -apple-system backs it up for older Safari, which
     predates system-ui. Helvetica and Arial are the floor.

     NOTE: a proportional face does not have fixed-width figures the way a mono
     does. .slide-count asks for them explicitly below; see the note there. */
  --font-label: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;

  /* THE SITE IS SET IN SERIF NOW. Georgia first because it is the one serif
     that is on effectively every machine and was drawn for screens — sturdy at
     text sizes, with a large x-height that holds up where Times goes spindly.
     The fallbacks are the usual descent for the machines that lack it.

     Everything that is not writing sets in --font-label instead, in the two
     rules below: the wordmark on its own, and the labels as a group. */
  --font-serif: Georgia, "Times New Roman", Times, serif;


  --gutter: clamp(24px, 3vw, 52px);
  --margin: clamp(16px, 12.8px + 0.8vw, 24px);
  --gap: calc(var(--margin) * 2); /* standard section gap, used below every page header */
  --max: 1360px;


  /* THE ICON HEIGHT. Font Awesome draws on a 512-unit em height with variable
     widths, so one height sizes the whole set and each mark keeps its own
     proportion — see components/Icon.tsx. Every rule below sets height and
     leaves width: auto; forcing a shared square would stretch the pause and the
     caret, which are 384 wide rather than 512.

     Ink at 20px, against the studio drawings these replace:
       play  16.9 x 18.8   (was 11.9 x 14.0)
       pause 15.0 x 17.5   (was  7.4 x 11.7)
       move  20.0 x 15.0   (was 20.6 x 16.5)
       arrow 15.3 x 13.0   (was 15.6 x 16.4)
       caret 13.8 x  7.8   (was  9.4 x 11.1) */
  /* THE THREE INDICATORS, and their sizes are the only property they have.
     Solid shapes have no line, so nothing here can be too thin or too heavy —
     which is what every previous version of this set kept failing at.

     They are NOT set to one number, because equal measures do not read as equal
     mass: a square fills its box, a circle covers about 79% of one, and a
     right-pointing triangle only half. So the triangle runs largest and the dot
     smallest, and the dot is meant to be small anyway — it sits inside a line of
     19px type where the others sit on artwork or beside a control. */
  --mark-play: 11px;    /* the triangle, on a video */
  --mark-caret: 8px;    /* the same triangle, turned, beside the filter label */
  --mark-dot: 7px;      /* nav and active filter */
  --mark-square: 8px;   /* drawn, unused, waiting for a job */
  --mark-toggle: 11px;  /* the details control: square, then bars on their side */






  /* THE SITE'S ONE TYPE SIZE. Everything is set in em against it, so this is
     the only number that moves type anywhere. */

  /* RESTORED. These eleven had no definition left — a regex pass of mine took
     them with the comment block they sat beside. --ease alone is read 47
     times, and a var() that resolves to nothing makes the whole declaration
     invalid, so every transition and easing on the site was silently off. */
  --ease: cubic-bezier(.16, 1, .3, 1);  --ease-panel: cubic-bezier(.4, 0, .2, 1);
  --t-dip: 0.18s;
  --t-quick: 0.12s;
  --t-base: 0.25s;
  --t-slow: 0.42s;
  --t-menu: 0.34s;
  --c-placeholder: var(--c-smoke);  /* image placeholder while loading */
  --page-bottom: 130px;           /* scroll breathing room at page bottom */
  --mark-nudge: 0px;

  --tracking: -0.01em;
  --fz: 19px;
  /* Mono label size. Flat, not a fraction of --fz: the mono is now only labels
     and captions, and a label wants to be a label at whatever size the prose
     happens to be — not to grow with it. */
  --fz-small: 13px;
}

/* Restored, verbatim. Registering these is what lets --dark and --enter be
   animated rather than snapping: an unregistered custom property has no type,
   so the browser cannot interpolate it. Losing them silently turns the wash and
   the intro into hard cuts. inherits: false stops the change at the element it
   is set on. */
@property --dark {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}
@property --enter {
  syntax: "<number>";
  inherits: false;
  initial-value: 1;
}

body {
  /* Restored: a deletion of mine took these three with the comment block they
     sat beside. Without them the page carried the UA's 8px body margin, no
     background and no colour. */
  margin: 0;
  background: var(--c-paper);
  color: var(--c-ink);

  /* ================= THE ONE TYPE RULE =================
     Family, weight, size and tracking for the whole site, declared here and
     nowhere else. Every other rule in this file inherits it — there are no
     font-family, font-size, font-weight or letter-spacing declarations left
     outside this block and the @font-face definitions at the top.

     So changing the site's type is changing these four lines. There is no
     second place it can be overridden from, which is the point: the last
     several rounds went wrong because size lived in three tokens and tracking
     in six rules, and moving one never moved the others.
     ===================================================== */
  font-family: var(--font-serif);
  font-size: var(--fz);
  font-weight: 400;
  letter-spacing: var(--tracking);
  line-height: 1.45;
  -webkit-font-smoothing: antialiased;
}

/* Form controls do not inherit type — the UA stylesheet gives them their own,
   and letter-spacing is not covered by the font shorthand. Without both, the
   one rule stops at the newsletter field and every button. */
* { box-sizing: border-box; }

/* RESTORED. Deleted by a regex pass of mine and missed when I put the others
   back — and it is not a nicety. Plenty of elements here carry the hidden
   attribute and are also given a display by a class: the next-project block
   ships both an <img> and a <video> so main.js can reveal whichever the cover
   needs, and .next-index-thumb sets display: block. A plain [hidden] rule loses
   that specificity fight, so the unused one was being rendered — 274px of empty
   placeholder between the thumbnail and the label, which is exactly the space
   that looked like padding.

   !important is the point of the rule: hidden means hidden regardless of what a
   class says about display. */
[hidden] { display: none !important; }

/* auto, not smooth. Lenis drives scrolling (see main.js) and native smooth
   behaviour fights it: the browser runs its own easing on top of Lenis's. */
html { scroll-behavior: auto; }

/* Lenis, minimal. The library ships a stylesheet for these; this is the part
   that matters, inlined rather than pulling a second file over the wire. */
html.lenis,
html.lenis body { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
.lenis.lenis-stopped { overflow: clip; }
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }

/* THE ONE RULE ONLY REACHES WHAT NOTHING ELSE CLAIMS.
   Setting type on body and letting it inherit works right up until an element
   the browser's own stylesheet has an opinion about — and a UA declaration ON
   the element beats inheritance from an ancestor at any specificity. So headings
   were still coming out at the browser's sizes and weights: h1 at 36px/700, h2
   at 27px/700, against a body of 18px/400.

   This is not a set of new call-outs. It is the list of elements the UA styles
   by default, handed back to the one rule. Everything here says "inherit", so
   there is still exactly one place any of these values is chosen.

   b, strong and headings keep their weight semantics in the markup for anything
   reading the page; they simply stop being drawn differently. i and em are left
   alone: italic is a style, not a size or a weight, and the site uses it. */
h1, h2, h3, h4, h5, h6,
b, strong,
small,
code, kbd, samp, pre,
th,
blockquote, figcaption, caption,
legend, optgroup, fieldset {
  font-family: inherit;
  font-size: inherit;
  font-weight: inherit;
  letter-spacing: inherit;
}

/* THE THINGS THAT ARE NOT SET IN THE SERIF.
   Everything else on the site inherits the serif from body. These are named
   once, here, rather than each carrying its own font-family — so the list of
   exceptions is readable as a list and cannot quietly grow a member in some
   other part of the file.

   THE LINE IS NAMES VERSUS LABELS. A project's client and title are its name —
   they are what the work is called, so they read as prose and set in the serif
   at reading size, on the project page and on the listing alike. Year, credit,
   NDA and image captions are labels: they annotate the work rather than name
   it, and they set in the label face at label size. The wordmark is neither and
   is handled on its own. */

/* THE WORDMARK IS FAMILY ONLY, and separated for that reason alone. It is the
   one label set at reading size, and it is standing in for a drawn SVG mark —
   so scaling it down with the labels would be work undone twice. */
.masthead-wordmark {
  font-family: var(--font-label);
}

/* EVERY LABEL IS THE SAME SIZE. Written as a token rather than a bare em,
   because .about-meta contains .about-meta-line: a relative size would resolve
   against the parent's already-reduced size and compound on the child, so one
   rule would produce two sizes depending on nesting. An absolute value is
   idempotent however deep it sits, which is why the nested pair can be listed
   here without thinking. */
.about-meta,
.about-meta-line,
.row-nda,
.slide-count,
.blk figcaption,
.pt-img figcaption {
  font-family: var(--font-label);
  font-size: var(--fz-small);
}

/* THE COUNTER NEEDS FIXED-WIDTH FIGURES and no longer gets them for free.
   A mono has one advance width for every glyph, so 01 and 09 occupied the same
   space and the number did not move as it counted. A proportional face draws a
   1 narrower than a 9, and the counter would twitch on every slide. Asking for
   tabular figures by name restores the mono's behaviour: SF, Segoe and Roboto
   all carry the feature, and on a face that does not, this declaration is
   ignored rather than breaking anything. */
.slide-count {
  font-variant-numeric: tabular-nums;
}

button, input, select, textarea { font: inherit; letter-spacing: inherit; }
button:focus { outline: none; }
button:focus-visible { outline: 2px solid var(--c-ink); outline-offset: 2px; }

a { color: inherit; text-decoration: none; }
img { display: block; max-width: 100%; }

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
}

.skip-link {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 1400;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  padding: 0.556em 0.889em;
  background: var(--c-ink);
  color: var(--c-paper);
}
.skip-link:focus {
  width: auto;
  height: auto;
  margin: 0;
  overflow: visible;
  clip: auto;
  white-space: normal;
}

.wrap {
  max-width: var(--max);
  margin: 0 auto;
  padding: 0 var(--margin);
}

/* iOS rubber-band overscroll at the top/bottom edge shows black, not white paper */
body[data-page="home"] {
  background: var(--c-ink);
}


/* ---------- view transitions ----------
   @view-transition opts the whole site in via CSS — most reliable placement.
   UA default crossfade is killed; new page fades in on all devices.
   Same-document filter/view toggles use html.vt-filter (higher specificity). */

@view-transition { navigation: auto; }

/* Kill UA default crossfade on every device */
::view-transition-old(root),
::view-transition-new(root) {
  animation: none;
  mix-blend-mode: normal;
}
/* The outgoing page holds still and the new one fades in over the top of it.
   Not a true crossfade: with mix-blend-mode normal, fading both at once dips
   through the page background mid-way, which flashes white.

   --t-base, not --t-quick. At 120ms this was three frames, and between two
   white pages three frames of crossfade are invisible, so every navigation
   looked instant and nobody noticed. Going to or from the black homepage the
   same 120ms read as a hard cut, because there the colour actually changes.
   The transition was never doing much; the homepage is just the only place
   you can see it. */
@media (prefers-reduced-motion: no-preference) {
  ::view-transition-new(root) {
    animation: var(--t-base) var(--ease-panel) both vt-filter-in;
  }
}

/* Same-document filter/view crossfade — higher specificity overrides root */
@media (prefers-reduced-motion: no-preference) {
  html.vt-filter::view-transition-old(root) {
    animation: var(--t-quick) ease-out both vt-filter-out;
  }
  html.vt-filter::view-transition-new(root) {
    animation: var(--t-quick) ease-out both vt-filter-in;
  }
}

@keyframes vt-filter-out { to { opacity: 0; } }
@keyframes vt-filter-in { from { opacity: 0; } }

/* ---------- tap feedback ---------- */
.row:active { opacity: 0.7; }
.card:active { opacity: 0.7; }

/* ---------- page top spacing ---------- */
/* Single source of truth — all non-home, non-404 pages start at the same
   distance below the nav. Individual page templates must not set their own
   top padding; strip any legacy ones below. */
body:not([data-page="home"]) #main {
  padding-top: var(--margin);
}

/* ---------- masthead ---------- */

/* IN FLOW, NOT PINNED. It used to stick to the top and then translate itself
   out of the way once you had scrolled 80px — so on any short scroll it held
   position, and on a long one it hung there for those 80px and then slid off
   under its own steam. Two movements for one gesture, and the second one was
   the page appearing to decide something.

   Scrolled away with everything else it is simply gone, at the speed you are
   scrolling, which is no speed at all to notice. Project pages already did this
   for a different reason and have read better for it. */
.masthead {
  padding: var(--margin) 0;
  position: relative;
  z-index: 1200;
}


/* ======================================================================
   THE WORDMARK, AS TYPE.

   Helvetica in caps at the body size — no separate face, no separate size, and
   nothing here that the rest of the site is not already doing. Everything the
   Zapf and the drawn versions needed is gone with them: no font-size, no
   line-height correction, no half-S crop, no padding to catch ink hanging
   outside its box. A grotesk's capitals sit inside their advances, which is why
   all of that was only ever needed by the faces that did not.

   Uppercased here rather than in the content, so what gets copied, read aloud
   and indexed stays "Strange Practice" and not a shout.
   ====================================================================== */
.masthead-wordmark {
  display: block;
  text-transform: uppercase;
  /* No tracking exception any more. This cancelled the old -0.02em, which was
     set for lowercase and closed caps up badly. The site's tracking is +0.01em
     now, which is the direction caps want anyway, so the mark inherits it and
     there is one figure governing the whole site instead of two. */
  margin-left: 0;
}

/* "Jordan Butcher" off, so the mark stands on its own. display: none rather
   than a visual-hiding trick, so it is gone for a screen reader too rather than
   being announced to someone who cannot see why. */
.masthead-person { display: none; }

/* THE HOVER: red, green, blue, switching hard, every letter a step out of phase
   with the one before — S red, T green, R blue, A red, and on along the word,
   each cycling all three.

   FILL, NOT COLOR. The shapes rest on fill: currentColor so they follow the
   masthead's theming, but the animation sets fill outright. Going through
   currentColor would have worked in principle and left the effect depending on
   a browser re-resolving an inherited keyword mid-animation — which is the kind
   of assumption this mark has already been bitten by once.

   NO GRADIENT, which is what the doubled percentages are for. A keyframe at 0%
   and the next at 33% makes the browser interpolate across the third of a
   second between them, which is a fade. Naming both ends of each band with the
   same colour leaves nothing to interpolate: the value holds flat, then jumps.

   THE OFFSET IS A NEGATIVE DELAY, AND IT WRAPS BY ITSELF. Each letter starts
   0.2s further into a 0.6s loop — one full band — so letter 0 opens on red, 1
   on green, 2 on blue, and 3 lands at 0.6s, which is where the loop began.
   Negative rather than positive, so the whole word is lit the instant the
   pointer lands instead of assembling itself over three seconds. */
@keyframes wordmark-rgb {
  0%,     33.32% { color: var(--p-red); }
  33.33%, 66.65% { color: var(--p-green); }
  66.66%, 100%   { color: var(--p-blue); }
}
.masthead-wordmark span { white-space: pre; }
.masthead-name a:hover .masthead-wordmark span {
  animation: wordmark-rgb 0.6s steps(1, end) infinite;
  animation-delay: calc(var(--i) * -0.2s);
}

/* Colour cycling on a loop is precisely what this setting asks us not to do.
   The hover still answers — one colour, held — so the link is not left feeling
   dead for anyone who has it on. */
@media (prefers-reduced-motion: reduce) {
  .masthead-name a:hover .masthead-wordmark span {
    animation: none;
    color: var(--p-red);
  }
}

/* Kept as its own rule even though it now says what .masthead already says. The
   project page needed this before the rest of the site did, for a reason that
   has not gone away: the closing field pushes content up as you reach the end,
   and a pinned masthead is a fixed obstacle for it to collide with — the client
   name printing over "d.b.a. Strange Practice". If the masthead is ever pinned
   again, this is the page that must not be. */
body[data-page="project"] .masthead {
  position: relative;
}

.masthead .wrap {
  max-width: none;
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  flex-wrap: wrap;
  gap: var(--margin);
}

/* RANGED LEFT, and it has to be: a centred flex item is positioned before its
   own negative margin applies, so the wordmark's offset would be measured from
   the middle of the line rather than from its left edge and the S would land
   inside the page instead of half off it. Centring was here for the homepage's
   business card, which is gone. */
.masthead-name a { display: flex; flex-direction: column; align-items: flex-start; }


.masthead-nav {
  display: flex;
  /* No gap. The spacing lives in the links' own padding instead, so the space
     between two labels is part of one target or the other rather than dead
     ground. On the homepage the masthead is pointer-events: none with only the
     links re-enabled, so a gap let the pointer through to the slideshow
     underneath and the slide cursor appeared mid-nav. */
  gap: 0;
  /* Cancels the last link's trailing padding so the nav still sits flush right. */
  margin-right: calc(var(--margin) / -2);
}

.masthead-nav a {
  display: inline-flex;
  align-items: center;
  position: relative;
  /* Half the old gap each side, so the visual rhythm is unchanged and the two
     halves meet in the middle. The negative block margin keeps the taller
     target from growing the masthead. */
  padding: 0.25em calc(var(--margin) / 2);
  margin-block: -0.25em;
  transition: opacity var(--t-base) var(--ease);
}

.masthead-name a {
  position: relative;
  transition: opacity var(--t-base) var(--ease);
}

.masthead-nav a:hover {
  opacity: var(--hover-fade);
}
/* THE WORDMARK IS NOT IN THAT LIST ANY MORE.
   It used to be, and an opacity: 1 was written further up to cancel it — which
   lost, because the two selectors have identical specificity and this one came
   later. Narrowing the rule is the honest fix: the mark answers a hover in
   colour, and does not need to answer it twice. */

/* ---------- category nav (homepage) ---------- */

/* Pins to the top and stays while the grid runs under it, the way the project
   info block does. The masthead above it is in normal flow and scrolls away, so
   this arrives at the top edge rather than parking under something. */
.filterbar {
  position: sticky;
  top: 0;
  /* The offset is padding on the sticky box, not a top value — exactly how the
     project info block does it. top: var(--margin) would leave a transparent
     strip above the bar that the grid scrolls through in full view; the padding
     is part of the element, so what passes behind the margin passes behind the
     blend with everything else. */
  padding-top: var(--margin);
  /* Under the masthead's 1200 while both are on screen, above everything in the
     grid once the masthead has gone. */
  z-index: 1100;
}

.filterbar .wrap {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: var(--gutter);
  padding: 0 var(--margin) var(--gap);
  flex-wrap: wrap;
}

.filterbar-categories {
  display: flex;
  gap: var(--gutter);
  flex-wrap: wrap;
}

/* ---------- category filter: a caret that expands an in-flow list below
   it on click, pushing the grid down, rather than a floating dropdown
   panel. No native <select> chrome. ---------- */

.filter-dropdown {
  position: relative;
}

.filter-dropdown-trigger {
  display: flex;
  align-items: center;
  /* Same figure the nav indicator keeps between its dot and its word. */
  gap: 0.444em;
  background: transparent;
  border: none;
  color: var(--c-ink);
  cursor: pointer;
  padding: 0;
  transition: none;
}
.filter-dropdown-trigger:hover,
.filter-dropdown.is-open .filter-dropdown-trigger {
  opacity: 0.6;
}

/* An outlined triangle ahead of the word, on the width the nav indicator uses,
   so a filter reads as the same kind of mark as the current page — one sits
   open, the other points.

   Drawn hollow on one hairline, the same weight as the pill's border and the
   plus's bar, so every mark on the site is one device pixel of line and nothing
   is heavier than anything else.

   non-scaling-stroke is what keeps that true, and it is set on the polygon as a
   presentation attribute rather than here. Stroke width is normally in user
   units and scales with the viewBox, so a 114-unit box drawn at 7px renders a
   1-unit stroke at about a sixteenth of a pixel — which is why the mark was
   invisible when this was a CSS declaration alone. The attribute is understood
   everywhere the property is not.

   overflow is visible because a stroke straddles its path: half of it falls
   outside the viewBox at the base and the apex, and the SVG root clips by
   default, which would shave those edges to half-hairlines.

   Sized in pixels, not ems, for the reason given on the indicator below. */
.filter-dropdown-caret {
  /* The triangle from the icon set, at caret size. It is drawn pointing right,
     which is the state it wants closed — at its own label, with the list still
     shut. Open it turns a quarter and points down at what it just revealed.

     A quarter, not a half. Turning a right-pointing triangle through 180 leaves
     it pointing left, which is a mirror rather than a direction and says nothing
     about being open. */
  flex-shrink: 0;
  width: var(--mark-caret);
  height: var(--mark-caret);
  translate: 0 var(--mark-nudge);

  /* A THIRD IN FROM THE BASE, NOT THE MIDDLE OF THE BOX. A triangle carries its
     weight near its base, so turning it about the box centre swings that mass
     across the box instead of pivoting it. Its centroid is a third along the
     width and halfway down — rotate there and the ink stays put while the point
     moves, which is the whole gesture. Compared against the box centre at 5x. */
  transform-origin: 33.333% 50%;
  transition: transform var(--t-base) var(--ease);
}
/* Ninety degrees, not one eighty. Closed it points at its own label; open it
   points down at the list it just revealed. Turning a right-pointing triangle
   through 180 would leave it pointing left, which is a mirror rather than a
   direction and says nothing about being open. */
/* THE MARK IS caret-up, SO CLOSED IS THE ROTATED STATE. It points down at the
   list it is about to reveal, and turns up when that list is open — the
   convention every dropdown uses. The old mark pointed right and turned 90
   degrees to point down; a caret has no rightward reading, so this is 180. */
.filter-dropdown.is-open .filter-dropdown-caret { transform: rotate(90deg); }

/* Gray on white, soft white on black. Quiet either way. */
.about-dropdown .filter-dropdown-trigger { color: var(--c-ink-soft); }

/* One label that holds still. It used to be two words cross-fading in a shared
   grid cell — sized to the wider of them so the icon never shifted. The label
   says the same thing in both states now, so there is nothing to fade and
   nothing that can move; the icon carries the whole state change. */

/* ZERO WIDTH, FULL HEIGHT. The menu has to do two contradictory things: take up
   vertical room, so opening it pushes the grid down, and take up no horizontal
   room, so the control stays as narrow as its own label. Left in flow at its
   natural width it set the parent to 138px — the width of "Music Packaging" —
   behind an 82px label, and that phantom 56px held the two filters apart no
   matter what the margin between them said.

   width: 0 gives the parent nothing to size to while the items still stack and
   still make height. align-items: flex-start stops them being squashed to the
   zero cross size, and each takes its natural width and hangs out to the right.

   The clip is the awkward part. The reveal animates max-height, which needs the
   overflow clipped vertically — but overflow: hidden clips both axes, and here
   that would hide the items entirely. overflow-x: visible with overflow-y:
   hidden is not available either; the spec turns the visible one into auto. So
   overflow stays visible and clip-path does the vertical cut on its own, with
   the right edge pushed out of the way. inset() bottom of 0 lands exactly on the
   box edge, which is what max-height is animating, so the reveal still wipes.

   This replaces measuring the trigger in JS and setting the width from it. That
   worked, but it could only run after first paint, so the filters visibly jumped
   57px on every load. */
.filter-dropdown-menu {
  width: 0;
  overflow: visible;
  clip-path: inset(0 -100vw 0 0);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.444em;
  max-height: 0;
  opacity: 0;
  padding: 0;
  /* --ease-panel, the same curve the project details opens on, rather than the
     site's expo. Expo puts nearly all its travel in the first fifth and then
     coasts, which is right for something arriving into place and wrong for a box
     whose height IS the animation — it read as a snap followed by a wait. */
  transition: max-height var(--t-menu) var(--ease-panel),
    opacity var(--t-menu) var(--ease-panel);
}
/* THE GAP ABOVE THE LIST IS A MARGIN ON ITS FIRST ITEM, not padding on the box.
   As padding it was transitioning at the same time as the height and was not
   part of what the height was measured against: main.js read the full height in
   the frame the class landed, when the padding was still at 0, so the menu
   animated to a target one margin short and then snapped the rest of the way
   when the height was released at the end. That was the jump on the last option.
   Inside the content, the space is measured with everything else and there is
   nothing left transitioning on its own. */
.filter-dropdown-menu > :first-child { margin-top: var(--margin); }
.filter-dropdown-menu > * {
  width: max-content;
  white-space: nowrap;
}
/* No max-height here any more. It was 15.556em — a guess big enough to clear the
   longest list, which meant the transition was travelling to a height the
   content never reached: the reveal finished early and the close spent its first
   frames collapsing empty space before anything moved. main.js measures the real
   height and animates to that, the same three steps the project details uses. */
.filter-dropdown.is-open .filter-dropdown-menu {
  opacity: 1;
}

.filter-dropdown-menu .filter-btn {
  text-align: left;
}

/* ---------- work / personal view toggle — same .filter-dropdown component
   as the category picker on the left, just preselected to "Commissions"
   (see data-view-dropdown in main.js) ---------- */

.view-toggle-btn {
  /* Flex and align-items: center, the same as .filter-btn. It was a block with
     the mark set vertical-align: middle, which is not the middle of anything —
     it aligns the box to the baseline plus half an x-height, so the ring sat
     low against caps and low by a different amount than the identical ring in
     the category list beside it. Centring on the line box is what the other one
     does, and it is the reason the two now agree. */
  display: flex;
  align-items: center;
  width: 100%;
  background: none;
  border: none;
  color: var(--c-ink-soft);
  cursor: pointer;
  padding: 0;
  transition: color var(--t-base) var(--ease);
}
.view-toggle-btn:hover { color: var(--c-ink); }
.view-toggle-btn.active { color: var(--c-ink); }
/* The same ring as the category list and the nav, not a second drawing of the
   same idea. This one was missed when the marks went hollow — it is the one dot
   on the site with its own rule rather than a shared one, which is exactly why
   it was the one left behind. Any further change to the mark belongs in all
   three places or in none. */
.view-toggle-btn.active::before {
  /* A SOLID DOT, WHICH NEEDS NO MASK AND NO SVG AT ALL.
     Everything that used to stand here went with the outline. The ring had been
     a border (WebKit snapped it to the device grid), then an inset box-shadow
     (held the weight but resolved its half pixel unevenly around the curve),
     then an SVG mask (because an SVG in background-image cannot see
     currentColor, and this mark sits in the masthead over an inverting
     slideshow). Each was a way of drawing a thin circle accurately.

     A filled circle is a background and a border-radius. It takes currentColor
     because it IS the colour, there is no line to hint, and nothing to keep
     round but the radius. */
  content: "";
  display: inline-block;
  width: var(--mark-dot);
  height: var(--mark-dot);
  border-radius: 50%;
  background: currentColor;
  flex-shrink: 0;
  margin-right: 0.444em;
  transform: translateY(calc(var(--mark-nudge) + 1px));
}

/* keeps the view-dropdown pinned right even when it's the only flex child */
.view-dropdown {
  margin-left: auto;
}

/* trigger content right-aligned so label+caret don't drift left when the
   dropdown expands to fit a wider option (e.g. "Commissions" > "Archive") */
.view-dropdown .filter-dropdown-trigger {
  /* Shrink to own content so "Archive" trigger doesn't inherit the wider
     container width set by "Commissions" in the menu below. Right-align. */
  width: max-content;
  margin-left: auto;
}

.filter-btn {
  display: flex;
  align-items: center;
  text-transform: none;
  background: none;
  border: none;
  color: var(--c-ink-soft);
  cursor: pointer;
  padding: 0;
  transition: color var(--t-base) var(--ease);
}

.filter-btn:hover { color: var(--c-ink); }
.filter-btn.active { color: var(--c-ink); }

/* active-state indicator — circle across all toggle types and the nav.

   The same width as the triangle now, where it used to be 0.74 of it. Solid, the
   two were balanced by area: a filled circle of width w covers 0.785w² against
   an equilateral triangle's 0.433w², so the triangle had to run 1.35x wider to
   carry the same weight. Hollow, there is no area to balance — the weight is all
   in the line, and the line is one pixel on both. Perimeter at equal width comes
   out 3.14w for the circle against 3.23w for the triangle, within three percent,
   so equal width is what now reads equal. If one moves, move both. */
.filter-btn.active::before,
.masthead-nav a.is-current::before {
  /* A SOLID DOT, WHICH NEEDS NO MASK AND NO SVG AT ALL.
     Everything that used to stand here went with the outline. The ring had been
     a border (WebKit snapped it to the device grid), then an inset box-shadow
     (held the weight but resolved its half pixel unevenly around the curve),
     then an SVG mask (because an SVG in background-image cannot see
     currentColor, and this mark sits in the masthead over an inverting
     slideshow). Each was a way of drawing a thin circle accurately.

     A filled circle is a background and a border-radius. It takes currentColor
     because it IS the colour, there is no line to hint, and nothing to keep
     round but the radius. */
  content: "";
  display: inline-block;
  width: var(--mark-dot);
  height: var(--mark-dot);
  border-radius: 50%;
  background: currentColor;
  flex-shrink: 0;
  margin-right: 0.444em;
  transform: translateY(calc(var(--mark-nudge) + 1px));
}
:root {
}

/* ---------- homepage slideshow ---------- */

body[data-page="home"] .masthead {
  position: absolute;
  top: 0; left: 0; right: 0;
  z-index: 5;
  background: transparent;
  color: var(--c-paper);
  pointer-events: none; /* let slideshow cursor + clicks pass through */
}
body[data-page="home"] .masthead.theme-light { color: var(--c-ink); }

/* THE TYPE IS INVERTED AGAINST THE PICTURE, not switched between two colours.
   One colour per element cannot work here: the nav sits at the middle of the
   frame and the slides are moving, so it spends most of its life straddling the
   seam between two of them — half the word over a white letterbox and half over
   a near-black cover. Whichever colour is picked, half the word disappears.
   Difference blending is decided per pixel, so the reversal sweeps across the
   lettering as the seam passes under it, which is also the thing to look at.

   White is the input, not a colour anyone sees: difference against white gives
   black, against black gives white, and the theme-light rules are overridden
   because an ink-coloured element blended against a dark slide stays dark.

   The blend goes on .masthead itself rather than on the name and nav inside it.
   An element blends with the backdrop of its own parent stacking context, and
   .masthead makes one of those — from inside it the slideshow does not exist. */
@supports (mix-blend-mode: difference) {
  /* UNGATED. These two carried .is-drift, a class the old horizontal engine put
     on the slideshow — nothing has set it since the vertical rebuild, so the
     caption quietly stopped blending and fell back to the per-slide luma
     verdict instead. On a mid-tone photograph that verdict called the slide
     light and painted the caption in ink, which is how "Art direction for
     Stetson" ended up black over a picture it could not be read on.

     Difference is decided per pixel, so it cannot make that mistake: the
     caption is always the inverse of whatever is directly behind it. */
  body[data-page="home"] .masthead,
  body[data-page="home"] .slide-caption {
    mix-blend-mode: difference;
  }
  body[data-page="home"] .masthead,
  body[data-page="home"] .masthead.theme-light,
  body[data-page="home"] .masthead-name,
  body[data-page="home"] .masthead-name.theme-light,
  body[data-page="home"] .masthead-nav,
  body[data-page="home"] .masthead-nav.theme-light,
  body[data-page="home"] .slide-caption,
  body[data-page="home"] .slide-cursor .slide-cursor-arrow,
  body[data-page="home"] .slide-cursor.theme-light .slide-cursor-arrow {
    color: #fff;
  }
  /* Nothing to ease between any more. */
  body[data-page="home"] .masthead-name,
  body[data-page="home"] .masthead-nav { transition: none; }
}
/* Per element once the drift is running. The name block sits at the left edge
   and the nav at the middle of the frame, which while the pictures are moving
   are regularly two different pictures — and on one picture, regularly two
   different brightnesses. main.js decides each from the luminance map of
   whatever is under it. This is the path for a browser with no blend modes;
   everywhere else the rules above win and main.js leaves the classes alone. */
/* Per element once the drift is running. The name block sits at the left edge
   and the nav at the middle of the frame, which while the pictures are moving
   are regularly two different pictures — and on one picture, regularly two
   different brightnesses. main.js decides each from the luminance map of
   whatever is under it. */
body[data-page="home"] .masthead-name,
body[data-page="home"] .masthead-nav {
  transition: color 0.28s var(--ease);
}
body[data-page="home"] .masthead-name.theme-light,
body[data-page="home"] .masthead-nav.theme-light { color: var(--c-ink); }
/* restore interactivity on the actual links only */
body[data-page="home"] .masthead-name a,
body[data-page="home"] .masthead-nav a { pointer-events: auto; }

body[data-page="home"] .slideshow {
  height: 100vh;
  height: 100dvh;
}

.slideshow {
  position: relative;
  height: calc(100vh - 3.889em);
  min-height: 23.333em;

  /* THE SCROLLER IS IN HERE, NOT THE PAGE.

     The document stays exactly one viewport tall, so there is no page
     scrollbar, no rubber-band at the ends, and no mobile browser chrome
     sliding away and back as you move. Scrolling happens inside this box and
     the page underneath never knows — which is the whole character of the
     thing: it reads as slides being replaced rather than as a page moving.

     overscroll-behavior: contain is what keeps that promise at the edges.
     Without it, a flick past the last slide hands the gesture up to the
     document and the address bar starts moving after all. */
  overflow-y: scroll;
  overflow-x: hidden;
  scroll-snap-type: y mandatory;
  overscroll-behavior: contain;
  /* SCROLL ANCHORING OFF. It exists to hold your place while things load above
     you, which it does by moving scrollTop and firing scroll events as each
     image arrives. In a snap scroller there is nothing to hold — every position
     worth being at is a snap point the browser already knows — and those events
     are indistinguishable from a visitor scrolling. */
  overflow-anchor: none;

  /* The bar would sit down the right-hand edge of a full-bleed photograph. */
  scrollbar-width: none;
  -ms-overflow-style: none;

  display: flex;
  flex-direction: column;

  background: var(--c-ink);
  user-select: none;
  -webkit-user-select: none;
}
.slideshow::-webkit-scrollbar { width: 0; height: 0; }

/* Motion is the browser's, so this is the one place it can be asked for less.
   The slides stay stacked and snapped; they simply arrive without the glide. */
@media (prefers-reduced-motion: no-preference) {
  .slideshow { scroll-behavior: smooth; }
}
.slideshow img {
  -webkit-user-drag: none;
  user-drag: none;
}

/* THE ADVANCE CURSOR IS OFF.

   It was the control: the frame was split into a previous half and a next half,
   the real pointer was hidden, and this arrow stood in for it and pointed the
   way a click would go. Scrolling is the control now, so the arrow would be
   pointing at a behaviour that no longer exists — and hiding the system cursor
   to show it would leave the visitor with no pointer and nothing to click.

   Hidden rather than removed, and the element still ships, so putting the
   horizontal engine back is a CSS change rather than a markup one. */
.slide-cursor { display: none; }
.slide-cursor {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 4;
  width: 2.222em;
  height: 2.222em;
  pointer-events: none;
  opacity: 0;
  --x: 0px;
  --y: 0px;
  transform: translate(var(--x), var(--y)) translate(-50%, -50%);
  transition: opacity var(--t-base) var(--ease);
}
.slide-cursor.is-visible { opacity: 1; }
/* NO font-size here. It carried 1.556em from when this was a "→" character, and
   width in em resolves against the element's own font size — so 1.7em was 1.7 x
   29.6px, not 1.7 x 19px, and the arrow drew at about 50px. With the font-size
   gone the em is the body's again and the number means what it says. */
.slide-cursor-arrow {
  width: 1.1em;
  height: 0.305em;
  display: flex;
  align-items: center;
  justify-content: center;
  /* matches the active slide's own type color, flipped below for light slides */
  color: var(--c-paper);
  fill: currentColor;
  transition: color var(--t-base) var(--ease);
}
.slide-cursor.theme-light .slide-cursor-arrow { color: var(--c-ink); }
.slide-cursor.is-prev .slide-cursor-arrow { transform: scaleX(-1); }

/* A COLUMN OF FULL-FRAME SLIDES, SCROLLED BY THE BROWSER.

   These used to be stacked at inset: 0 and crossfaded, with a transform-driven
   horizontal drift layered on top by script. Now they are simply in flow, one
   under the next, each exactly the height of the frame, and the scroller above
   snaps between them.

   The whole interaction is native: wheel, trackpad, touch, Page Up/Down, Home
   and End, and the arrow keys all work without a line of JavaScript, and they
   work with the platform's own inertia rather than an approximation of it.

   scroll-snap-stop: always is the difference between this and a long page that
   happens to snap. Without it a fast flick sails through four slides and lands
   wherever momentum ran out; with it every gesture advances exactly one, which
   is what makes it read as a slideshow rather than as scrolling. */
.slide {
  position: relative;
  height: 100%;
  flex: none;
  scroll-snap-align: start;
  scroll-snap-stop: always;
  color: var(--c-paper);
  display: flex;
  align-items: center;
  justify-content: center;
}
.slide[data-theme="light"] { color: var(--c-ink); }

/* DRIFT. The class is added by main.js once it has measured the slideshow and
   placed every slide, so the crossfade above stays the whole story until then —
   and stays the whole story for good if the visitor asked for less motion or if
   the script never runs. Untransformed the slides all sit at inset: 0 on top of
   each other, which is exactly what the crossfade wants and exactly what a row
   must not look like, so opacity only opens up in the same frame the transforms
   land. */
.slideshow.is-drift .slide {
  opacity: 1;
  z-index: auto;
  transition: none;
  /* OFF BY DEFAULT, and switched on per slide by main.js for the two that are
     actually on screen. Nine full-bleed slides all promoted at once is nine
     viewport-sized compositor layers — around 170MB at 2x on a 1275px window —
     and the compositor spends its budget shuffling layers for pictures nobody
     can see. Hidden slides keep their layout, so nothing measured off them moves;
     they simply stop being painted. */
  visibility: hidden;
  will-change: auto;
}
.slideshow.is-drift .slide.is-onstage {
  visibility: visible;
  will-change: transform;
}


/* dead-center text fallback for an image that hasn't loaded yet. main.js
   removes this element outright on the image's load/error event — not
   just covered by the photo, since a contained/letterboxed slide doesn't
   fill this box edge-to-edge and would leave the label peeking out at the
   sides forever otherwise. No spinner, no animation, just the literal
   state, matter-of-fact. */
.slide-media { position: relative; overflow: hidden; }

/* Dot screen — THE HOMEPAGE SLIDESHOW ONLY, and full-bleed slides at that.
   Contained slides have clean letterbox bars with no compression to mask, so
   dots only go where video fills edge-to-edge. main.js adds and removes this
   element; there is no matching rule for it to inherit anywhere else.

   IT USED TO BE ON THE PROJECT PAGE TOO, as .video-frame::after. Removed: the
   screen was drawn for a slide running edge-to-edge behind type, where it hides
   banding in a gradient and gives the type something to sit on. A video block
   on a project page is a framed picture on white with nothing over it, so the
   dots were not masking anything — they were just a 20% black veil on the work,
   and at 2px they moiré against the video's own grid. If a project video ever
   needs one, it wants its own value, not this one. */
.slide-video-dots {
  position: absolute;
  inset: 0;
  background-image: radial-gradient(circle, rgba(0,0,0,0.2) 0.8px, transparent 0.8px);
  background-size: 2px 2px;
  pointer-events: none;
  z-index: 1;
}

.slide-loading {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 1;
}

/* Desktop fit — applies above 700px */
@media (min-width: 701px) {
  .slide--desktop-full .slide-media { position: absolute; inset: 0; }
  .slide--desktop-full .slide-media img:not(.slide-video-poster),
  .slide--desktop-full .slide-media video { width: 100%; height: 100%; object-fit: cover; }

  /* contained: image/video floats dead-center on a definite box (vw/vh, not a
     percentage of an auto-height ancestor) so it can never overflow */
  .slide--desktop-contained .slide-media {
    width: calc(var(--slide-size-desktop, 56) * 1vw);
    height: calc(var(--slide-size-desktop, 56) * 1.143vh);
    max-height: 88vh;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .slide--desktop-contained .slide-media img:not(.slide-video-poster) {
    max-width: 100%;
    max-height: 100%;
    width: auto;
    height: auto;
    object-fit: contain;
    display: block;
  }
  /* Video fills the container box; object-fit: contain letterboxes the
     content inside. This prevents the element from snapping to a new
     size when HLS ABR switches quality levels (which changes videoWidth/
     videoHeight but should never change the element's layout dimensions). */
  .slide--desktop-contained .slide-media video {
    width: 100%;
    height: 100%;
    object-fit: contain;
    display: block;
  }
}

/* Mobile fit — applies at/below 700px */
@media (max-width: 700px) {
  .slide--mobile-full .slide-media { position: absolute; inset: 0; }
  .slide--mobile-full .slide-media img,
  .slide--mobile-full .slide-media video { width: 100%; height: 100%; object-fit: cover; }

  .slide--mobile-contained .slide-media {
    width: calc(var(--slide-size-mobile, 56) * 1vw);
    height: calc(var(--slide-size-mobile, 56) * 1.143vh);
    max-height: 88vh;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .slide--mobile-contained .slide-media img:not(.slide-video-poster) {
    max-width: 100%;
    max-height: 100%;
    width: auto;
    height: auto;
    object-fit: contain;
    display: block;
  }
  .slide--mobile-contained .slide-media video {
    width: 100%;
    height: 100%;
    object-fit: contain;
    display: block;
  }
}

.slide-caption {
  position: absolute;
  left: var(--margin);
  right: var(--margin);
  width: 100%;
  bottom: var(--margin);
  z-index: 2;
  display: flex;
  flex-direction: column;
  gap: 0;
}

/* THE CAPTION BELONGS TO A SLIDE THAT HAS ARRIVED, NOT ONE IN TRANSIT.
   It fades out the moment the frame starts to move and comes back when the
   next one settles, so the type is only ever legible over a still picture —
   words sliding up the screen attached to a moving photograph read as debris.

   Gated behind the attribute that main.js sets rather than being opacity: 0 by
   default, because the thing that puts it back is script. Without that gate a
   failed or blocked bundle would leave every caption permanently invisible;
   this way the fade is something JavaScript adds, and its absence just means
   the captions sit there as they always did. */
.slideshow[data-caption-fade] .slide-caption {
  opacity: 0;
  /* A shade quicker than --t-slow's 0.42s. The caption answers a movement the
     eye has already finished, so it should not still be arriving after the
     picture has settled. */
  transition: opacity 0.32s var(--ease);
}
.slideshow[data-caption-fade] .slide.is-current .slide-caption { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .slideshow[data-caption-fade] .slide-caption { transition: none; }
}
.slide-caption .slide-count { opacity: 1; }
.slide-caption .slide-title { max-width: 50ch; }

/* The pinned caption. Hidden until main.js has the drift running, at which point
   the travelling ones are hidden instead, so exactly one caption is ever on the
   page and the no-script and reduced-motion paths are untouched. */
/* The pinned caption is gone. Each slide names itself again, from its own bottom
   left corner, and travels with its picture — but it stops at the frame's left
   margin instead of riding off the edge, and holds there until the next slide
   arrives over the top of it.

   That hold is a counter-translate: the caption sits inside the slide, so it is
   already being carried left by the slide's transform, and main.js pushes it back
   by exactly as much as the slide has gone past the margin. Nothing else moves.

   The words, not the box: the caption is a full-width column, so its own rect
   says nothing about where the type actually is. Letting the two spans hug their
   text gives main.js something real to measure the wipe against. */
.slide-caption .slide-title,
.slide-caption .slide-count { align-self: flex-start; }
.slideshow.is-drift .slide-caption {
  will-change: transform, opacity;
  /* Above every picture. Nothing occludes these; the fade in place() is the only
     thing that takes them off. */
  z-index: 3;
}

@media (max-width: 700px) {
  .slide-caption .slide-title {
    max-width: 62vw;
  }
}


.work-section .wrap { max-width: none; }

.work-list {
  display: grid;
  grid-template-columns: 1fr 1fr;
  column-gap: calc(var(--margin) * 2);
  overflow: hidden; /* clips ::before bleed on outer edges */
  padding: 0;
  position: relative;
}

/* Bottom rule closing the last row */
.work-list::before {
  content: '';
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 1px;
  background: var(--c-rule);
  pointer-events: none;
}

/* Odd number of visible items: last item is alone in left column — extend its
   top rule across the full grid so the empty right cell isn't open-topped.
   JS sets .is-grid-tail--odd because :last-child can't see hidden rows. */
.work-list > .row.is-grid-tail--odd::before {
  right: -9999px;
}

/* Vertical rule between columns */
.work-list::after {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  width: 1px;
  background: var(--c-rule);
  pointer-events: none;
}

.row {
  position: relative;
  display: flex;
  align-items: flex-start;
  gap: var(--margin);
  width: 100%;
  margin: 0;
  background: none;
  border: none;
  padding: var(--margin) 0;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}

.row.is-hidden { display: none; }

/* Horizontal rule — extends var(--margin) beyond the row on each side so it
   bridges the column gap. overflow:hidden on .work-list clips the outer bleed,
   leaving a continuous rule that stops at the padding boundary on both sides. */
.row::before {
  content: '';
  position: absolute;
  top: 0;
  left: calc(-1 * var(--margin));
  right: calc(-1 * var(--margin));
  height: 1px;
  background: var(--c-rule);
}

.empty-state {
  margin: 0;
  padding: clamp(1.111em, 0.978em + 0.6vw, 1.444em) 0;
  color: var(--c-ink-soft);
}

/* ---------- archive empty state ---------- */

.personal-soon {
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 15vh 0;
}

.personal-soon-ascii {
  line-height: 1.2;
  color: var(--c-ink-soft);
  margin: 0;
  white-space: pre;
}

.row-meta {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
}

.row-client {
  display: block;
  line-height: 1.45;
  color: var(--c-ink);
}

.row-title {
  display: block;
  line-height: 1.45;
  /* Stated, not inherited. .row-client declares its own ink; this used to take
     the parent's, so coming off a dark wash the client went straight to its
     value in one step while the title chased .row-meta's colour — which was
     itself mid-transition. A transition pointed at a moving target arrives about
     twice as late, which is the title lagging behind the client. */
  color: var(--c-ink);
}

/* Records are set in italics, the way a record is set in italics anywhere else —
   the artist stays roman and the release takes the slope, so a caption reads as
   "Botch, <cite>Unifying Themes Redux</cite>" rather than as two names.

   *=, not ~=. data-categories is comma-joined rather than space-separated, so
   the space-delimited match browsers give you for free does not apply to it. The
   three keys share no substrings, so a plain contains is exact here; a fourth
   category with one of these inside its name would not be, and this is where
   that would surface. */
.row[data-categories*="music-packaging"] .row-title {
  font-style: italic;
}





.row-nda {
  align-self: flex-start;
  margin-top: 0.222em;
  background: transparent;
  color: var(--c-ink);
  border: 1px solid var(--c-ink);
  padding: 0.167em 0.278em 0.056em;
  line-height: 1;
}

/* Mobile keeps the old arrangement: a fixed height, width from the ratio. The
   ratio is stated rather than left to the content because the media inside is
   absolutely positioned now and so contributes no intrinsic size — without it
   the box would collapse to nothing wide. */
.row-thumb {
  position: relative;
  flex-shrink: 0;
  height: clamp(5.944em, 2.611em + 12vw, 13.111em);
  aspect-ratio: var(--ar, 1.4);
  width: auto;
  overflow: hidden;
  background: var(--c-placeholder);
}

/* Pinned to the box rather than sized at 100% of it. The thumbnail's height now
   comes from aspect-ratio, and a percentage height resolving against a height
   that was itself derived is the sort of thing engines disagree about — Safari
   most of all, and it is a <video> that suffers. inset: 0 needs no such
   resolution. */
.row-thumb img,
.row-thumb video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* ---------- spot wash ----------
   A fixed sheet behind everything on the work page. main.js paints it with the
   hovered project's own ink and clears it on the way out, so the site holds no
   colour of its own and every colour you see on it came off a press.

   Behind the page shell, not in front: the thumbnails keep their own edges and
   the wash reads as the paper changing rather than a tint laid over the work.
   Transitions on background-color only, which the compositor handles without
   touching layout. */
/* The wash is a background on the page shell — an ordinary in-flow element —
   rather than a fixed sheet laid over the viewport. That is not a stylistic
   choice. macOS Safari tints its own toolbar by looking for a position: fixed
   or position: sticky element at the edge of the viewport and reading its
   background-color, in preference to the body's. A fixed, full-viewport,
   coloured sheet is precisely the thing it reaches for, which is why the
   browser chrome went warm the moment a thumbnail was hovered. Backgrounds on
   ordinary elements are never sampled, so Safari falls back to body's paper
   and its chrome stays where it was. theme-color does not help here; that meta
   only overrides the tint on iOS.

   The shell is min-height: 100dvh and holds the whole page, so the colour still
   fills the sheet edge to edge and scrolls with it. */
body.is-washing .page-shell { transition-duration: 0.32s; }

.page-shell {
  background-color: transparent;
  /* Same curve as the thumbnails' dim so the colour and the field move as one
     thing, but ASYMMETRIC: 0.32s arriving, 0.2s leaving. Coming back to paper is
     not an event, it is the page getting out of the way, and it should not take
     as long as the thing you asked for. The longer value lives on the washing
     state above. */
  transition: background-color 0.2s cubic-bezier(0.22, 0.61, 0.36, 1);
}

/* Type flips with the ink underneath it. data-spot-theme is set on <html> by
   main.js from a value worked out at build time with the same WCAG luminance
   test the homepage slides use, so a dark wash gets paper type and a light one
   keeps ink. Scoped to the work page: nothing else has a wash to sit on.
   Transitioned over the same duration as the wash itself, or the words would
   flip a beat before the colour arrives.

   THE CHILDREN OF .row-meta HAVE TO BE NAMED. .row-client sets its own
   var(--c-ink) and .row-medium its own rgba(0,0,0,.6), so a colour on the
   parent alone inherits into nothing and the caption stayed black on every
   wash, however dark. Same trap as any "set it on the wrapper" rule: inherited
   values lose to a declaration on the element itself, at any specificity. */
body[data-page="work"] .masthead-nav a,
body[data-page="work"] .masthead-name a,
body[data-page="work"] .filter-dropdown-trigger,
body[data-page="work"] .filter-btn,
body[data-page="work"] .view-toggle-btn,
body[data-page="work"] .row-meta,
body[data-page="work"] .row-meta .row-client,
body[data-page="work"] .row-meta .row-title {
  transition: color 0.32s cubic-bezier(0.22, 0.61, 0.36, 1), opacity 0.32s cubic-bezier(0.22, 0.61, 0.36, 1);
}
html[data-spot-theme="dark"] body[data-page="work"] .masthead-nav a,
html[data-spot-theme="dark"] body[data-page="work"] .masthead-name a,
html[data-spot-theme="dark"] body[data-page="work"] .filter-dropdown-trigger,
html[data-spot-theme="dark"] body[data-page="work"] .row-meta,
html[data-spot-theme="dark"] body[data-page="work"] .row-meta .row-client,
html[data-spot-theme="dark"] body[data-page="work"] .row-meta .row-title {
  color: var(--c-paper);
}
/* THE FILTER LIST, WHICH WAS BEING LEFT BEHIND ON EVERY WASH.
   .filter-dropdown-trigger — the "All Work / Current" line — was in the lists
   above from the start, but the buttons the trigger opens were not, so hovering
   a thumbnail took the whole page dark and left Brand, Music Packaging and
   Ephemera sitting in ink on it.

   Three selectors rather than one because .filter-btn declares its own colour in
   three states, and the note further up applies exactly: an inherited value
   loses to a declaration on the element itself, at any specificity. Adding the
   base class alone would have fixed the resting state and left the active item
   and anything under the pointer still dark.

   The resting state keeps its step back from full strength, the way .row-medium
   does, so the list reads as secondary on a wash just as it does on paper. */
/* The Current/Archive toggle was never in these lists — it sat in ink on every
   wash, exactly as .filter-btn did before it was added. Same three states for
   the same reason: it declares its own colour in each, and a declaration on
   the element beats an inherited one at any specificity. */
html[data-spot-theme="dark"] body[data-page="work"] .filter-btn,
html[data-spot-theme="dark"] body[data-page="work"] .view-toggle-btn {
  color: var(--c-paper);
}
html[data-spot-theme="dark"] body[data-page="work"] .filter-btn:hover,
html[data-spot-theme="dark"] body[data-page="work"] .filter-btn.active,
html[data-spot-theme="dark"] body[data-page="work"] .view-toggle-btn:hover,
html[data-spot-theme="dark"] body[data-page="work"] .view-toggle-btn.active {
  color: var(--c-paper);
}
/* Kept a step back from the client name, the way rgba(0,0,0,.6) does on paper. */


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

/* Desktop only. The wash is a hover effect, and on a touch screen it either
   never fires or fires on a tap that was meant to open the project — either way
   it is noise. Done in CSS rather than by skipping the listeners in JS so that
   resizing across the breakpoint takes effect immediately, and so the contrast
   flip can be switched off in the same place. A wash-less page with paper type
   on paper would be invisible. */
@media (max-width: 700px) {
  body[data-page="work"] .page-shell { background-color: transparent !important; }
  html[data-spot-theme="dark"] body[data-page="work"] .masthead-nav a,
  html[data-spot-theme="dark"] body[data-page="work"] .masthead-name a,
  html[data-spot-theme="dark"] body[data-page="work"] .filter-dropdown-trigger,
  html[data-spot-theme="dark"] body[data-page="work"] .filter-btn,
  html[data-spot-theme="dark"] body[data-page="work"] .filter-btn:hover,
  html[data-spot-theme="dark"] body[data-page="work"] .filter-btn.active,
  html[data-spot-theme="dark"] body[data-page="work"] .view-toggle-btn,
  html[data-spot-theme="dark"] body[data-page="work"] .view-toggle-btn:hover,
  html[data-spot-theme="dark"] body[data-page="work"] .view-toggle-btn.active,
  html[data-spot-theme="dark"] body[data-page="work"] .row-meta,
  html[data-spot-theme="dark"] body[data-page="work"] .row-meta .row-client,
  html[data-spot-theme="dark"] body[data-page="work"] .row-meta .row-title {
    color: var(--c-ink);
  }
}

/* ---------- work grid, desktop ----------
   Justified rows: thumbnails keep their own aspect ratio, sit at a shared row
   height, and wrap. Rows fill left to right and stop where they stop, so the
   ragged right edge is the layout working, not a bug. No gutters, no rules, no
   copy in the grid.

   The copy is not removed from the markup. .row-meta becomes a fixed element on
   hover, so the same DOM serves the mobile list and the desktop caption, and
   the caption lands in the same place every time without shifting anything. */
@media (min-width: 701px) {
  /* Fixed row height. Measured off the reference: every thumbnail there is
     exactly 250px tall and nothing stretches, so this is a real height, not a
     target to be scaled.

     10vw is almost never an integer, so this lands on a fraction. justifyLines()
     in main.js rounds it to a whole pixel and writes the result back onto the
     list, because a fractional height puts the boundary between two stacked
     lines mid-pixel, where the browser antialiases both edges against whatever
     is behind them — the page. That is the hairline of white that shows between
     some thumbnails and not others. It is done there rather than with CSS
     round() because round() inside a custom property is only rejected at
     substitution time, which would leave --thumb-w invalid and collapse every
     row to auto on an engine that does not support it. */
  /* WIDTH is the fixed dimension now, and height follows each cover's own ratio.
     The range is picked to land on roughly the same average footprint the old
     height clamp produced once multiplied by a typical ratio, so the page keeps
     its density. justifyLines() rounds it to a whole pixel for the same reason
     it used to round the height: a fractional edge meeting another fractional
     edge antialiases against the page and shows as a hairline. */
  :root { --thumb-w: clamp(200px, 130px + 13vw, 340px); }

  .work-list.is-view-active {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-start;
    column-gap: 0;
    row-gap: 0;
    /* Pulls out of .wrap's side padding, but stops 1em short of the window on
       each side rather than going edge to edge. */
    margin: 0 calc(1em - var(--margin));
    overflow: visible;
  }
  /* Rules belong to the old two-column list. */
  .work-list::before,
  .work-list::after,
  .row::before { display: none; }

  /* Nothing grows or shrinks. Every thumbnail is --thumb-w wide and as tall as
     its own ratio makes it, packed left to right until the line is full, tops
     aligned and bottoms left ragged.
     --lead is a leading gap the template hands out on a fixed pattern, which is
     what stops the leftover space from all piling up at the end of a row. That
     was the difference between this reading as scattered and reading as broken.
     Justifying the rows instead was tried and rejected: it made every row fill
     the width, so a row with three items in it blew them up enormous. */
  .row {
    /* Width stated outright, computed from --ar. Deriving it from aspect-ratio
       with width:auto left the box to be sized from its content, and Safari
       lets a replaced element's own intrinsic size win that negotiation — a
       <video> has one, an <img> in this markup does not, which is why only the
       two video thumbnails ever misbehaved and only in Safari. Nothing here is
       measured from content now. */
    /* Widths are rounded up to whole pixels by justifyLines() too: a ratio times
       a height lands on a fraction almost every time, and two fractional edges
       meeting mid-pixel leave the same seam. This is the pre-script value. */
    flex: 0 0 var(--thumb-w);
    /* Without this a flex item refuses to go narrower than its content, which is
       how the ratio got to argue with the width in the first place. */
    min-width: 0;
    /* THE SCATTER IS A MARGIN. It was briefly padding, to close the gaps between
       hover targets — but .row is an anchor, and padding put its clickable area
       across the empty page beside the image: 412px of link for a 228px
       thumbnail, so clicking nothing navigated to a project. The row is the
       image and nothing else. Crossing a gap therefore does leave the link,
       which is the intended behaviour: the hover belongs to the thumbnail. */
    padding: 0;
    gap: 0;
    /* Tops line up, bottoms fall where the ratio puts them. */
    align-items: flex-start;
    margin-left: var(--lead, 0px);
  }
  .row-thumb {
    /* Width comes from the row, height from the ratio. This is the safe
       direction for aspect-ratio: the width is definite, so the height is
       derived from it. The reverse — width auto, height stated — is what let a
       <video>'s intrinsic size win the negotiation in Safari and blow the two
       video thumbnails out. */
    position: relative;
    width: 100%;
    /* height: auto is doing real work here. The base rule states a height, and a
       box with BOTH a definite width and a definite height ignores aspect-ratio
       outright — so every thumbnail came out the same height, and the flex item's
       automatic minimum size (that height times the ratio) then overrode the
       fixed basis and made the widths vary instead. Exactly backwards. */
    height: auto;
    aspect-ratio: var(--ar, 1.4);
    background: var(--c-placeholder);
    /* A hairline between every thumbnail, done with a transparent border rather
       than a gap on the list, so the rows stay contiguous and border-box keeps
       the width unchanged.

       background-clip: padding-box, so the fill stops at the border and the page
       shows through the hairline — it goes transparent and takes the spot wash
       with it. Letting the grey paint under the border instead drew a light grey
       rule around every thumbnail on the page, which is the thing this is not
       meant to be.

       The reason it was taken off once before is the free edge: a line of page
       colour down the side of a thumbnail with no neighbour reads as an outline
       drawn around the image, and against a black cover it is glaring. So the
       border only exists where there is something on the other side of it —
       main.js marks the row that ends each line and the rows on the bottom line,
       and those drop the border on that side. Border-box keeps the outer width
       either way, so nothing rewraps. */
    background-clip: padding-box;
    border-right: 1px solid transparent;
    border-bottom: 1px solid transparent;
  }
  .row.is-line-end .row-thumb { border-right-width: 0; }
  .row.is-last-line .row-thumb { border-bottom-width: 0; }

  /* While a thumbnail is hovered, every other one drops back. The caption sits
     in the bottom corner of the window over whatever happens to be there, and on
     a full grid that is a wall of imagery; dimming the rest gives it a field to
     read against and points at the one being named. The lit row is marked by
     main.js rather than matched with :hover, because the wash holds through a
     60ms grace period and the row under the pointer is not always the lit one
     during it. */
  /* THE THUMBNAIL DIMS, NOT THE ROW. .row-meta is a child of .row, so fading the
     row faded the caption with it — the newly lit row's caption had to climb the
     whole ramp from 0.55, which is the text appearing to sit under the overlay
     and then come forward. Opacity on a parent cannot be undone by a child at
     any value, so the only fix is to not put it on the parent. */
  .work-list .row-thumb {
    /* ease-out, not the expo curve. Expo spends most of its time in a very long
       tail, which at this duration reads as lag rather than glide. */
    transition: opacity 0.2s cubic-bezier(0.22, 0.61, 0.36, 1);
  }
  /* 0.55, not a quarter. A deep drop reads as the page switching off; the point
     is only to let the caption sit clear of the imagery, and just over half does
     that with the work still legible. */
  /* The longer, arriving value. Leaving keeps the shorter one above. */
  .work-list.is-washing .row-thumb { transition-duration: 0.32s; }
  /* Down from 0.55. The dim exists to let the caption sit clear of the
     imagery; a tenth further back separates the lit row more firmly. */
  .work-list.is-washing .row .row-thumb { opacity: 0.45; }
  .work-list.is-washing .row.is-lit .row-thumb { opacity: 1; }

  /* One line, bottom left, only while a thumbnail is under the pointer. */
  .row-meta {
    position: fixed;
    left: var(--margin);
    bottom: var(--margin);
    z-index: 20;
    flex: none;
    flex-direction: row;
    align-items: baseline;
    gap: 0.35em;
    opacity: 0;
    pointer-events: none;
    /* Snappier than --t-quick. The caption is a response to the pointer, so it
       wants to feel instant rather than animated. */
    transition: opacity 0.08s var(--ease);
  }
  /* Tied to .is-lit only. :hover is on the ROW, which now contains the scatter
     gap, so it would show the caption for a project whose image the pointer has
     not reached. */
  .row.is-lit .row-meta,
  .row:focus-visible .row-meta { opacity: 1; }.row-meta .row-client,
.row-meta .row-title { display: inline; line-height: 1.45; }
  /* No text-transform: the scope vocabulary is title case in the CMS now, and
     lower-casing it here contradicted the meta line on the project page. */

  /* Only the client takes a comma, and only when a title follows it. The scope
     used to be the third item, which is why the title carried one too — without
     it the caption ended on a stray comma. */
  .row-meta .row-client:not(:last-child)::after { content: ","; }
  .row-meta .row-nda { display: none; }
}

/* ---------- work list, mobile ----------
   The same object the archive is: two columns of cards, thumbnail on top and
   copy beneath. It used to be a single-column list with the copy on the left
   edge and the thumbnail on the right, which read fine on its own but meant
   switching between Current and Archive changed the whole shape of the page
   rather than just its contents.

   Every number below is the archive's, not a new set: 2em between columns, 1em
   of margin and 1em of padding above each cell, 0.444em from picture to words.
   If one moves, move both. */
@media (max-width: 700px) {
  .work-list::before,
  .work-list::after,
  .row::before { display: none; }

  .row {
    display: flex;
    flex-direction: column;
    gap: 0;
    /* Spacing carried on the cell rather than as a grid row-gap, so rows hidden
       by a filter collapse without leaving a hole where they were. */
    margin-top: 1em;
    padding: 1em 0 0;
    align-items: stretch;
  }

  /* Full width of its own column, height from the cover's ratio — the same
     bargain the desktop grid makes, so a tall cover stays tall here too.

     order: -1 because .row-meta comes first in the markup. That was deliberate
     for the old side-by-side list, where source order alone put the copy on the
     left and the picture on the right and no direction rule was needed. Stacked,
     the same order puts the words above the picture, which is not the archive's
     arrangement. Reordering here rather than in the markup leaves the desktop
     grid — where .row-meta is a fixed-position hover caption and its position in
     the flow is irrelevant — completely untouched. */
  .work-list .row-thumb {
    order: -1;
    width: 100%;
    height: auto;
    aspect-ratio: var(--ar, 1.4);
  }
  .work-list .row-meta { margin-top: 0.444em; }
}

/* Both view panels are hidden until one is marked active, which stops them
   flashing on top of each other before JS runs. The display values live HERE,
   never in JS: main.js used to set an inline display, and an inline style beats
   every stylesheet rule, which silently pinned the work list to its old
   two-column grid no matter what the CSS said. */
[data-view-panel] { display: none; }
.work-list.is-view-active { display: grid; }
.grid.is-view-active { display: grid; }
/* Restated after the base rule on purpose. The desktop work grid is declared
   further up the file, and a media query carries no extra specificity, so the
   plain .work-list.is-view-active above would otherwise win and put the desktop
   list back into a two-column grid. */
@media (min-width: 701px) {
  .work-list.is-view-active { display: flex; }
}

/* auto-fill, not auto-fit: lone cards (Personal may have just one) stay at
   normal card width with trailing empty tracks, not stretched full-bleed. */
.grid {
  grid-template-columns: repeat(auto-fill, minmax(min(15.556em, 100%), 1fr));
  column-gap: var(--margin);
  row-gap: 3em;
  align-items: start;
  padding: 0 0 var(--page-bottom);
}

/* archive: denser, smaller floor (170px vs 280px) — more items at a glance */
.grid--archive {
  grid-template-columns: repeat(auto-fill, minmax(min(9.444em, 100%), 1fr));
  column-gap: 2em;    /* 1em each side of vertical rule = matches padding-top: 1em */
  row-gap: 0;         /* spacing moved to card padding-top so rules land cleanly */
  overflow: hidden;   /* clips the ±9999px card rule bleed */
  position: relative;
  padding-bottom: 0;        /* override .grid — ::after handles bottom closure */
  margin-bottom: var(--page-bottom); /* footer spacing outside the vline area */
}

/* Closing bottom rule — sits as the last grid item, spanning all columns.
   margin-top matches the card margin-top so spacing above is symmetric.
   Hidden when grid is empty (personal-soon placeholder present). */
.grid--archive:not(:has(.personal-soon))::after {
  content: '';
  grid-column: 1 / -1;
  height: 1px;
  background: var(--c-rule);
  margin-top: 1em;
}

/* Per-row rules on each card: extend far left/right, clipped by overflow:hidden
   on the grid. All cards in the same row share the same top Y, so their ::before
   elements overlap into one continuous line across the full width.
   margin-top gives space ABOVE the rule; padding-top gives space BELOW. */
.grid--archive .card {
  position: relative;
  margin-top: 1em;
  padding-top: 1em;
}
/* JS marks the first card in each visual row with is-row-start.
   Only that card draws the rule — prevents opacity stacking across columns. */
.grid--archive .card::before {
  content: none;
}
.grid--archive .card.is-row-start::before {
  content: '';
  position: absolute;
  top: 0;
  left: -9999px;
  right: -9999px;
  height: 1px;
  background: var(--c-rule);
}
/* All first-row cards sit flush at top — no gap before the opening rule.
   JS marks every card in row 1 with is-first-row. */
.grid--archive .card.is-first-row {
  margin-top: 0;
}

.grid--archive .card-meta {
  margin-top: 0.444em;
  display: flex;
  flex-direction: column;
  gap: 0.111em;
}

.grid--archive .card-meta .title {
  color: var(--c-ink);
}

.grid--archive .card-meta-title {
  color: var(--c-ink-soft);
}

.card {
  display: block;
  margin: 0;
  width: 100%;
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}


.card-frame {
  /* A SHAPE BEFORE THE PICTURE ARRIVES, so the grid is laid out at its final
     height and nothing shifts when the images land — the placeholder grey is a
     sized box rather than a collapsed div that inflates.

     The inline style overrides this with the cover's real ratio whenever the
     CMS knows it. This default covers the cases it does not: a Mux video, whose
     still is never measured, and any cover missing width or height. */
  aspect-ratio: var(--card-ar, 1.4);
  overflow: hidden;
  background: var(--c-placeholder);
}

.card-frame img {
  width: 100%;
  height: auto;
  display: block;
  transition: opacity var(--t-slow) var(--ease);
}

.card:hover .card-frame img { opacity: 0.86; }

.card-meta {
  margin-top: calc(var(--margin) / 2);
}

.card-meta .title {
  display: block;
}

/* ---------- information page ----------
   ONE CENTRED COLUMN, READ TOP TO BOTTOM. How to reach the studio, who you are
   reaching, then what it does and who it has done it for.

   It was two columns — contact and portrait side by side, the writing filling a
   gutter underneath — which read as a directory entry rather than as a page
   anyone was meant to reach the end of. */

.info-page .wrap { max-width: none; }

/* The one width this page is built on. 34em is where running prose stops being
   comfortable to read; everything else lines up to it rather than to a figure
   of its own. */
:root {
  --info-measure: 34em;
  /* The portrait's size. It is a 3000 x 3000 square in the CMS, so this is its
     height as much as its width — there is no separate vertical lever without
     cropping the frame. */
  --info-portrait: 200px;

  /**
   * THE DROP UNDER THE NAV, and only that. Two margins is the site's own
   * figure, measured on the projects listing — this page matches every other
   * page there rather than deciding for itself.
   */
  --info-gap: calc(var(--margin) * 2);

  /**
   * THE SPACE BETWEEN SECTIONS, derived from the drop rather than typed as a
   * second number. Multiplying keeps the relationship visible: if the site's
   * margin ever changes, both move together and stay in proportion.
   *
   * It was equal to the nav drop and the sections ran into each other — four
   * headings down one column need more room between them than the masthead
   * needs above the first one, because a heading has to read as opening
   * something rather than as continuing the paragraph above it.
   */
  --info-section: calc(var(--info-gap) * 1.3);
}

/* ---- the head: contact, then the portrait ---- */

/**
 * THE PAIR, SIDE BY SIDE, CENTRED AS ONE OBJECT.
 *
 * Not a two-column page — a single block that happens to have two parts, sitting
 * in the middle of the measure with the reading column underneath it. The older
 * layout that failed put contact on the left of the whole window and the
 * portrait on the right of it, which left the writing to fill a gutter. Here the
 * pair is only as wide as it needs to be.
 *
 * align-items: center rather than flex-start. The details are three short lines
 * against a tall picture, and hanging them off the top left the block bottom-
 * heavy — a column of white under the type and none under the photograph.
 */
/**
 * ONE MEASURE FOR THE WHOLE PAGE. The head and the reading column below it are
 * the same width, from the same token, so the details start exactly where the
 * prose starts and the picture ends exactly where the prose ends. Two figures
 * that had to match would eventually stop matching.
 */
/**
 * THE SAME DROP FROM THE NAV THE REST OF THE SITE TAKES.
 *
 * Measured on the projects listing at a 900px window: 59.5px from the bottom of
 * the wordmark's glyphs to the top of the first filter's. That is three times
 * --margin, arrived at in three pieces — the masthead's own padding-bottom,
 * then the space between the two boxes, then the filterbar's padding-top.
 *
 * This page has only the first of those three, because nothing here carries
 * padding of its own, so it was starting a full 40px high. Two more margins put
 * it on the same line as every other page.
 */
.info-head {
  /* A COLUMN, CENTRED. It was a row with the details on one side and the
     portrait on the other, held apart by space-between. The page now reads
     downward — picture, handle, place — with everything on one axis, so there
     is nothing to distribute and nothing to sit opposite. */
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--margin);
  max-width: var(--info-measure);
  /* THE PORTRAIT STARTS ON THE FILTERS' INK, NOT ON THEIR BOX.

     First this was --info-gap, 48px, which margin-collapsed out through
     .info-page and .wrap and carried the whole section down — the picture began
     at 147.55 against the filter row's 123.55. Matching the boxes fixed that and
     was still wrong, which an overlay of the two pages showed plainly: a line of
     text does not start at the top of its box.

     The filter label is 19px Georgia on a 27.55px line. Half the leading is
     3.28, the font's ascent is 17, and the tallest ink in "All Work" reaches
     14.36 above the baseline — so the first mark of the page appears 5.91px
     below the box that holds it. An image has no such gap: its box top is its
     ink top. Measured off the font rather than reasoned about.

     So the picture takes that 5.91 as well, and the two pages start on the same
     visible line rather than the same invisible one. It is a constant because
     CSS cannot ask a font for its metrics; if the type changes, re-measure. */
  margin: calc(var(--margin) + 5.91px) auto 0;
}

/* Centred inside its own half. It sits on the column's left edge as a block,
   and the three lines centre on each other within it — which is what stops a
   pill, a handle and a two-line address of three different widths from reading
   as a ragged stack. */
.info-details {
  text-align: center;
  flex: 0 1 auto;
  min-width: 0;
}


/* Darker on hover, not lighter. On a white page a lift toward the light takes
   the white label with it — the same reversal the portal's blue needed. */

.info-social { margin: 0.9em 0 0; }
.info-social a { color: var(--c-ink); text-decoration: underline; }
.info-social a:hover { opacity: 0.6; }

/* The page's own ink, not the soft grey. It is one short line now rather than a
   block of logistics, and at that length grey reads as disabled rather than as
   secondary. */
.info-address {
  margin: 0.9em 0 0;
  color: var(--c-ink);
}

/* SMALLER THAN THE COLUMN IT SITS IN, deliberately. At the text's own width it
   read as a hero image; held well inside it, it reads as a portrait — the same
   move the project pages make by setting their meta smaller than their copy. */
.info-portrait {
  /* margin-inline: auto belongs here as well as align-items on the parent —
     the parent centres it as a flex item, this centres it if it is ever put
     back in normal flow. */
  margin-inline: auto;
  width: 100%;
  max-width: var(--info-portrait);
  height: auto;
  display: block;
  background: var(--c-placeholder); /* same placeholder fill as .card-frame */
}

/* ---- the reading column ---- */

/* Narrower than the head. Contact details are three short centred lines and can
   sit wide; running prose cannot, and 34em is where it stops being comfortable. */
.info-read {
  max-width: var(--info-measure);
  margin: var(--info-section) auto 0;
  text-align: left;
}

/**
 * FIRST-LINE INDENT INSTEAD OF A BLANK LINE'S WORTH OF AIR. Paragraphs still
 * carry space between them, but the indent is what makes a wall of prose read
 * as set type rather than as a stack of separate statements.
 */
/**
 * THE INDENT MEANS "ANOTHER PARAGRAPH", so it only belongs where there is a
 * previous one to be another of.
 *
 * A first paragraph has nothing above it to continue from, and neither does the
 * one directly under a heading — indenting those is the mistake that makes a
 * page look typeset by a word processor rather than by anybody. The lists under
 * Services and Select clientele are exactly that case.
 *
 * Wrapped lines are not affected either way: text-indent moves the first line of
 * a block and no others, so a five-line paragraph is indented once. A run of
 * single lines that should not indent at all is one paragraph with line breaks
 * in it — shift-return in the editor rather than return.
 */
.info-read p {
  margin: 0 0 1.5em;
  text-indent: 2.4em;
}

/**
 * ONE RULE DECIDES IT, AND IT IS THE NUMBER OF LINES.
 *
 * Every paragraph indents. The only thing that takes the indent away is coming
 * out as a single line, which main.js measures after render — a standalone line
 * with space around it is not continuing the paragraph above it, so it has
 * nothing to indent from.
 *
 * The book convention of leaving the first paragraph flush was here and is
 * gone. It is the right rule for a chapter opening under a drop or a heading,
 * and this column has neither: the first paragraph arrives cold under a
 * photograph, where flush left reads as an exception nobody asked for rather
 * than as a convention. Same for the lists under Services and Select clientele,
 * which are single lines often enough to be handled by the rule above without
 * a rule of their own.
 */
.info-read p.is-single-line { text-indent: 0; }

/**
 * The section headings take the same caps as the wordmark, so the two places
 * this page shouts are shouting in the same voice.
 *
 * Tracking comes back up for the same reason it does on the wordmark: these are
 * caps set at a fitting drawn for lowercase, and left alone they close up.
 * Slightly more here than on the wordmark, because "SELECT CLIENTELE" is a long
 * run of caps at reading size where the wordmark is two short words.
 */
.info-read h2 {
  /* letter-spacing removed: it cancelled the old site-wide negative, which is
     positive now and wants inheriting rather than overriding. */
  text-transform: uppercase;
  margin: var(--info-section) 0 1.1em;
  text-indent: 0;
}

/* .no-wrap is gone with the arrays. Each capability and each client name used
   to be its own span so a title like "Sunny Day Real Estate" could not break
   across two lines; typed as prose in the editor there are no spans to hang it
   on. That was the trade made knowingly when these became rich text, and it is
   recorded here rather than left as a behaviour that quietly disappeared. */

/**
 * DIMMED UNTIL READ. Each block sits at 0.2 and comes up to full as it crosses
 * a line near the bottom of the window, so the page lights up just ahead of
 * where the eye already is.
 *
 * OPACITY, NOT COLOUR — which is what makes it survive on any background rather
 * than assuming a white one, and is how the reference does it too.
 *
 * ONE WAY. Nothing dims again once you are past it: text that fades out behind
 * you punishes looking back, and looking back at a paragraph is a normal thing
 * to do.
 *
 * The no-JS state is lit, not dimmed. A page whose copy is stuck at 20% because
 * a script failed is worse than one that never had the effect.
 */
.info-read > *,
.info-prose > * {
  transition: opacity 0.5s var(--ease);
}

/* .info-prose is a wrapper the CMS's HTML had to be dropped into, not a block
   of its own — main.js lights its children individually, so it must never be
   dimmed itself or it would fight them. */


@media (prefers-reduced-motion: reduce) {
  /* The dimming is the motion here, so removing the transition is not enough —
     the copy has to simply be legible. */

}

/**
 * WHAT THE EDITOR IS ALLOWED TO PRODUCE. Twill's wysiwyg can emit emphasis,
 * strong, links and lists; the stylesheet has to have an answer for each or the
 * first time one is used the page will quietly fall back to browser defaults —
 * Times for the italic, blue underlined for the link.
 */
.info-read em { font-style: italic; }
.info-read strong { font-style: italic; }
.info-read a { color: inherit; text-decoration: underline; }
.info-read a:hover { opacity: 0.6; }

.info-read ul,
.info-read ol {
  margin: 0 0 1.5em;
  padding-left: 1.2em;
  text-indent: 0;
}
.info-read li { margin-bottom: 0.35em; }

@media (max-width: 760px) {
  /* Stacked, and the picture leads. Side by side at this width would give the
     details about eleven characters a line.

     THE SAME GAP ABOVE THE DETAILS AS BELOW THEM. Stacked, the contact block
     has a picture over it and the prose under it, and it was sitting nearer the
     picture than the writing — which read as though it belonged to the
     photograph rather than standing on its own. One figure, used twice, so the
     block is centred in its own space and the two cannot drift apart. */
  .info-head {
    flex-direction: column;
    gap: var(--info-section);
  }
  .info-details { text-align: center; }
  .info-portrait { order: -1; max-width: 60vw; }
}

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



.about-dropdown { flex-shrink: 0; }


.project-body {
  display: flex;
  align-items: stretch;
  overflow: hidden;
}

.project-canvas {
  flex: 1 1 auto;
  min-width: 0;
  padding: 0 var(--margin) var(--margin);
  display: flex;
  flex-direction: column;
  gap: calc(var(--margin) / 2);
}


/* A quote sets exactly like body copy. The only move is the opening quotation
   mark hanging into the margin so the text edge stays flush with the paragraphs
   above and below it. hanging-punctuation would be the proper tool but Chrome
   still does not support it, so this is a negative first-line indent, which
   every engine gets right. */
.blk-rich-text blockquote {
  margin: 0;
  font: inherit;
  color: inherit;
  text-indent: -0.42em;
}

.blk-rich-text blockquote + p {
  margin-top: 0.4em;
}


@media (min-width: 701px) {


  /* No colour changes on open. The column is light enough that ink type,
     the soft-grey meta line and the nav all read against it exactly as they do
     against white, which is the point: one set of colours in both states. */
  /* Covers the toggle's label swap, and nothing else. It used to dip the nav
     links too, because they sat over the black column and had to change colour
     while invisible. The column is grey now and everything stays ink, so the
     nav has no reason to move. All this hides is "Read details" becoming
     "Close details".

     The ramps are --t-dip (180ms) expressed against the 0.68s span: 26% out,
     hold, 74% back. They were 100ms on the site's expo curve, which puts most
     of the opacity change into the first 30ms of the ramp, so the nav read as
     vanishing and reappearing rather than fading. Length and curve both had to
     change. If the span changes, recompute both stops as 0.18 / span. */
  @keyframes nav-swap {
    0%   { opacity: 1; }
    26%  { opacity: 0; }   /* out by 180ms, before the colour flips */
    74%  { opacity: 0; }   /* held while the column travels */
    100% { opacity: 1; }   /* back by 680ms, just after the column lands */
  }
  /* Applied to the whole control — label AND icon — so the thing goes away and
     comes back rather than half of it dipping while the other half visibly
     turns. It used to dip only the label wrapper, which left the plus rotating
     into an X in plain sight and the word changing while it was still legible.
     Both now swap inside the window where nothing is on screen.

     On the TRIGGER, never on the two label spans inside it. A running animation
     outranks normal declarations, so animating the spans forced both to opacity
     1 and printed "Read details" and "Close details" on top of each other. The
     trigger dips; the spans keep their own swap. */


  /* The shared dropdown rule dims an open trigger to 60%, which is fine as a
     grey on white but leaves the close control nearly invisible on black.
     Full strength when open; hover still dims. */


  /* Colour switches instantly but only once the type is already invisible.
     Cross-fading it turned the copy mid-grey while the column was still
     black, which read as mud rather than a fade. */
.about-meta { transition: opacity var(--t-dip) var(--ease-panel), color 0s var(--t-dip); }
}

/* Desktop: the details column travels with the images instead of scrolling
   away and leaving a tall empty gutter on image-heavy projects. */
@media (min-width: 701px) {
  /* `clip` rather than `hidden`: both contain the width animation, but
     `hidden` makes this a scroll container, which silently kills sticky
     on everything inside it. */
  .project-body { overflow: clip; }

}

/* Project meta: one sentence under the writing, carrying year, role, scope and
   credits. It was a labelled definition list until 2026-08-13; as a paragraph
   there is no label punctuation to argue about and it reads as a colophon. */
.about-meta {
  margin: 0;
  /* Inherited, not stated. It used to declare its own softened ink, which meant
     it did not follow the block it sits in — and inside the sticky info block,
     which is inverted per pixel, a 60% black composited to #666 and then blended
     to #999 on white. Legible if you looked for it, and easy to read as empty
     space rather than as a line of type. It takes the same colour as the copy
     above it now. */
  color: inherit;
  /* The two lines are year/role and credits — separate facts rather than a
     running paragraph, so they want air between them, not the tighter setting
     that keeps sentences reading as one block. */
  line-height: 2;
  max-width: 60ch;
}
/* Year and role on one line, credits on the next. */
.about-meta-line { display: block; }

/* Every image sits on the same light grey as the thumbnails and the archive
   cards. It shows while a picture is still decoding, so nothing lands as a white
   hole on a white page, and it gives a letterboxed or transparent image a ground
   to sit on rather than dissolving into the paper. */
.pt-img { margin: 0; width: 100%; background: var(--c-placeholder); }
.pt-img img { width: 100%; display: block; }
.pt-img figcaption { color: var(--c-ink-soft); margin-top: 0.444em; }

@media (max-width: 700px) {
  .project-body {
    position: relative;
    flex-direction: column;
    overflow: visible;
  }
  /* DOM order is canvas-first; flip on mobile so the panel opens between
     topbar and images. z-index:1 needed — order:1 paints before order:2
     (canvas), so without it the panel falls behind the hero image. */
  .project-canvas { order: 2; }
}

.blk { margin: 0; position: relative; isolation: isolate; }

/* percentage-width variants — desktop only; always full-width on mobile */
.blk--pct {
  max-width: var(--blk-width, 100%);
  margin-left: auto;
  margin-right: auto;
}
.blk--pct.blk--left  { margin-left: 0; }
.blk--pct.blk--right { margin-right: 0; }
@media (max-width: 700px) {
  .blk--pct { max-width: 100%; margin-left: 0; margin-right: 0; }
}

.blk figcaption {
  position: absolute;
  /* em against the caption's own 13px mono, not the 19px body — so these are
     11.7px and 14.3px on the page. */
  bottom: 0.9em;
  left: 1.1em;
  margin: 0;
  color: white;
  mix-blend-mode: difference;
  pointer-events: none;
}

/* rich text block — full-width on mobile, centered column on desktop.
   Vertical padding on both breakpoints so the text breathes the same amount
   against the image blocks above and below it. */
.blk-rich-text {
  padding-top: calc(var(--margin) * 2);
  padding-bottom: calc(var(--margin) * 2);
}
.blk-rich-text p { margin: 0; max-width: 60ch; }
.blk-rich-text p + p { margin-top: 0.75em; }
.blk-rich-text h2,
.blk-rich-text h3,
.blk-rich-text h4 {
  margin: 1.2em 0 0.3em;
}
.blk-rich-text ul,
.blk-rich-text ol {
  margin: 0.5em 0;
  padding-left: 1.3em;
  max-width: 60ch;
}
.blk-rich-text li { margin-bottom: 0.2em; }
.blk-rich-text li:last-child { margin-bottom: 0; }
.blk-rich-text ul { list-style: disc; }
.blk-rich-text ol { list-style: decimal; }
.blk-rich-text a { text-decoration: underline; transition: opacity var(--t-base) var(--ease); }
.blk-rich-text a:hover { opacity: 0.6; }
/* One weight everywhere, so bold in CMS copy sets at the same weight as the
   text around it. It still carries its meaning in the markup for anything
   reading the page; it just does not shout on screen. */
/* desktop only: center column */
@media (min-width: 701px) {
  .blk-rich-text {
    max-width: 56ch;
    margin-left: auto;
    margin-right: auto;
  }
}


.blk-full-bleed img { width: 100%; display: block; }

/* grid — what the old fixed two-up block became once the column count stopped
   being baked into the CSS. The gap is half a margin so that blocks of
   different kinds stacked together read as one system rather than three. */
.grid-row {
  display: grid;
  /* minmax(0, 1fr), not 1fr. A bare 1fr is minmax(auto, 1fr), so a cell whose
     content refuses to shrink past some width drags its column wider than its
     neighbours: measured 523.7 against 526.7 in a two-up, which put the seam
     1.5px off centre and out of true with the full-bleed block above it. A zero
     floor makes the columns equal by construction. */
  grid-template-columns: repeat(var(--grid-cols, 2), minmax(0, 1fr));
  gap: calc(var(--margin) / 2);
}
/* THE FRAME AROUND A VIDEO CELL. It takes the cell's sizing from the rule
   below, and position: relative is what the play/pause button is placed
   against — the button is absolute at the frame's bottom right, exactly as it
   is in the video block. */
.grid-video { position: relative; }
.grid-video video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* video as well as img: a grid cell can be either, and they must fill their
   cell identically or a video would sit unsized beside a picture.

   THE COMMENT ABOVE USED TO SIT INSIDE THIS SELECTOR LIST, between
   `.grid-row img,` and the line after it. A comment does not end a selector, so
   the trailing comma reached past it and joined `.grid-row img` to
   `.grid-video { position: relative }` — and grid pictures lost their width
   entirely. Unloaded they measured 0 and the cell collapsed; loaded they took
   their natural size and overflowed. That is the broken grid. Selector lists
   and comments do not interleave; the comment goes above the list. */
.grid-row img,
.grid-row video,
.grid-row .grid-video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  background: var(--c-placeholder);
}
/* A phone caps at two columns whatever the desktop count is — four across at
   390px is four postage stamps. A pair drops to one, because halves are the
   whole point of a pair and two halves of a phone are neither. */
@media (max-width: 700px) {
  .grid-row { grid-template-columns: repeat(2, 1fr); }
  .grid-row:has(> :last-child:nth-child(2)) { grid-template-columns: 1fr; }
}

/* flexible grid — twelve columns, each picture spanning as many as it was
   given. A row ends when the next span will not fit, so 8 + 8 is two rows with
   the second half empty rather than two images quietly rebalanced to 6 + 6.

   grid-auto-rows: 1fr makes every row in one block the same height, which is
   what keeps this a laid-out page and not a contact sheet. Pictures crop to
   fill, so the bottom edge of a row is a straight line. */
.flex-grid-row {
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  grid-auto-rows: 1fr;
  gap: calc(var(--margin) / 2);
}
/* See .grid-row — a cell is a picture or a video, styled the same. */
.flex-grid-row img,
.flex-grid-row video,
.flex-grid-row .grid-video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  background: var(--c-placeholder);
  /* The inline span is a desktop instruction. Without a default here, an image
     whose span never got written would be one column wide rather than half. */
  grid-column: span 6;
}
/* On a phone the twelve columns collapse to one and every span is overridden.
   Halving each span instead would put a 2 at one sixth of 390px, which is not
   a picture. !important because the span arrives as an inline style — the one
   place on this site where the cascade cannot reach it. */
@media (max-width: 700px) {
  .flex-grid-row { grid-template-columns: 1fr; grid-auto-rows: auto; }
  .flex-grid-row img,
  .flex-grid-row video,
  .flex-grid-row .grid-video { grid-column: 1 / -1 !important; height: auto; }
}


.blk-slideshow { overflow: hidden; cursor: pointer; background: var(--c-placeholder); }
.slideshow-track {
  display: flex;
  transition: transform var(--t-slow) var(--ease);
  will-change: transform;
}
.slideshow-slide {
  flex: 0 0 100%;
  min-width: 100%;
  position: relative;
}
.slideshow-slide img { width: 100%; display: block; }
.slideshow-counter {
  position: absolute;
  bottom: 1em;
  right: 1em;
  color: var(--c-paper);
  opacity: 1;
  mix-blend-mode: difference;
  pointer-events: none;
}

.video-frame { position: relative; background: var(--c-placeholder); }
.video-frame video { width: 100%; display: block; aspect-ratio: 16 / 9; }
.video-toggle {
  position: absolute;
  bottom: 0; right: 0;
  border: none;
  background: none;
  color: white;
  mix-blend-mode: difference;
  cursor: pointer;
  padding: 0.7em;
  /* THE WHOLE INSET IS THE PADDING NOW. 3px stood here while the marks were
     Font Awesome, which drew about 9px of air inside its own artboard — the
     padding only had to make up the difference. The solid primitives fill their
     box edge to edge, so 3px put the ink 3px off the corner and the buttons sat
     on the video's edge. */
  line-height: 0;
}
/* ONE SQUARE BOX FOR BOTH MARKS — see the note on the shared rule below. */
.video-toggle svg { width: var(--mark-play); height: var(--mark-play); display: block; }
.video-toggle .icon-play { display: none; }
.video-toggle[data-playing="false"] .icon-pause { display: none; }
.video-toggle[data-playing="false"] .icon-play { display: block; }

/* Poster image for video slides — absolutely overlaid so it obeys the same
   object-fit: contain as the video element below it. Fades out on play. */
.slide-video-poster {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  display: block;
  z-index: 1;
}
.slide-video-poster.is-hidden { display: none; }

.slide-play-toggle {
  position: absolute;
  bottom: 0; right: 0;
  z-index: 3;
  border: none;
  background: none;
  color: white;
  mix-blend-mode: difference;
  cursor: pointer;
  padding: 0.7em;
  /* THE WHOLE INSET IS THE PADDING NOW. 3px stood here while the marks were
     Font Awesome, which drew about 9px of air inside its own artboard — the
     padding only had to make up the difference. The solid primitives fill their
     box edge to edge, so 3px put the ink 3px off the corner and the buttons sat
     on the video's edge. */
  line-height: 0;
  /* Hidden until JS has positioned it correctly (on first play event). */
  opacity: 0;
  transition: opacity var(--t-quick);
}
.slide-play-toggle.is-visible { opacity: 1; }
/* ONE SQUARE BOX FOR BOTH MARKS — see the note on the shared rule below. */
.slide-play-toggle svg { width: var(--mark-play); height: var(--mark-play); display: block; }
.slide-play-toggle .icon-play { display: none; }
.slide-play-toggle[data-playing="false"] .icon-pause { display: none; }
.slide-play-toggle[data-playing="false"] .icon-play { display: block; }

/* HOLLOW, ON ONE HAIRLINE — THE RULE THE REST OF THE SITE ALREADY FOLLOWS.
   The filter caret states it plainly a few hundred lines up: every mark on the
   site is one device pixel of line and nothing is heavier than anything else.
   These two were the exception — a solid triangle and two solid bars, filled
   with currentColor, sitting over artwork beside an arrow and a caret that are
   both drawn as outlines. That is what made them look borrowed from somewhere
   else rather than part of the set.

   The play triangle and the caret are one shape rotated, so the mark that means
   "this plays" and the mark that means "this opens" are the same drawing. The
   pause is two bars on the same box.

   non-scaling-stroke goes on the shapes as a presentation attribute rather than
   here: stroke-width is in user units and scales with the viewBox, so a
   114-unit box drawn at 13px would render a 1.25-unit stroke at a fraction of a
   pixel. Same reason the caret sets it there.

   overflow: visible because a stroke straddles its path. The marks are inset
   inside their viewBox now rather than touching its edge, so nothing is
   actually clipped — it stays as insurance for the half-pixel the stroke sits
   either side of the path.

   ONE SQUARE BOX, AND BOTH MARKS CENTRED IN IT. Each mark used to carry its own
   width against a shared height, which meant the pause sat in a narrower box
   than the play and the two were only ever aligned along one edge. Both boxes
   are the same square now, both marks are drawn inset and centred inside a
   100x100 viewBox, so the pause sits exactly where the play sat — same centre,
   same footprint — and swapping them moves nothing.

   The triangle is 68 by 80 of that 100, near enough equilateral (0.85 against
   0.866) rather than filling the square, and the pause is two lines 44 apart
   and 72 tall. At --toggle-mark that is a 8.3 x 9.8px triangle and a 5.4px gap
   between 8.8px lines. */
.video-toggle svg,
.slide-play-toggle svg {
  /* Filled paths from the icon set, not strokes — the weight is drawn into the
     geometry, so there is nothing to set here but the colour, and currentColor
     is what carries the difference blend from the button. */
  fill: currentColor;
}


/* ---------- page shell ---------- */

html, body {
  /* `clip`, not `hidden`. With overflow-x:hidden the browser computes
     overflow-y to auto, which makes body a scroll container and silently
     disables position:sticky anywhere inside the page (the project details
     panel, the masthead). `clip` suppresses the same horizontal overflow
     without creating that scroll container. */
  overflow-x: clip;
}

/* Load animation: page rises from black to white, then images/videos stagger in.
   Images are kept hidden via CSS during the black phase; JS reveals them in order. */
@media (prefers-reduced-motion: no-preference) {
  /* data-sp-init is set in <head> before first paint and removed after the load
     animation. This prevents images flashing visible for one frame before the
     animation covers them.
     A DATA ATTRIBUTE, not a class, and the same goes for body's data-page.
     React renders <html> and <body> without either, so a class written by the
     inline scripts disagrees with React's tree and hydration can wipe it. That
     lost the homepage its black background and its hidden media for a single
     frame, which is the flash on arriving at /. React does not reconcile data
     attributes it never rendered, so the state survives hydration. */
  html[data-sp-init] .page-shell img,
  html[data-sp-init] .page-shell video,
  html[data-sp-init] .page-shell .row-thumb,
  html[data-sp-init] .page-shell .card-frame {
    opacity: 0;
  }

  /* Starts fully black (not faded in) — the shell's first painted frame must
     already be dark, or navigating away from a black page (the homepage)
     flashes white through the gap before the pulse builds. The VT crossfade
     (180ms vt-filter-in on new root) supplies the soft entry instead. */
  @keyframes page-pulse {
    0%   { background-color: #000; opacity: 1; }
    25%  { background-color: #000; opacity: 1; }
    100% { background-color: transparent; opacity: 1; }
  }
  [data-page-shell].is-cycling {
    animation: page-pulse 0.7s ease-out forwards;
  }
  [data-page-shell].is-cycling img,
  [data-page-shell].is-cycling video,
  [data-page-shell].is-cycling .row-thumb,
  [data-page-shell].is-cycling .card-frame {
    opacity: 0;
  }
}

/* isolation:isolate for stacking context without a GPU compositor layer */
.page-shell {
  isolation: isolate;
  position: relative;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}

#main {
  flex: 1;
}


/* ---------- mobile nav ---------- */

.nav-short { display: none; }

@media (max-width: 700px) {
  .work-list { grid-template-columns: 1fr 1fr; column-gap: 2em; row-gap: 0; overflow: visible; }
  .work-list::after { display: none; }
  .work-list > .row.is-grid-tail--odd::before { right: 0; }
  .row::before { left: 0; right: 0; }

  .masthead .wrap { align-items: flex-start; }
  .masthead-nav {
    flex-direction: column;
    align-items: flex-end;
    gap: 0.333em;
  }
  .nav-full { display: none; }
  .nav-short { display: inline; }

  .grid--archive { grid-template-columns: 1fr 1fr; } /* always 2 columns */
}

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

.lightbox {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--c-paper);
  color: var(--c-ink);
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--t-slow) var(--ease);
  z-index: 1100;
  cursor: pointer;
}
.lightbox.is-open {
  opacity: 1;
  pointer-events: auto;
}

.lightbox-close {
  position: absolute;
  top: var(--margin);
  right: var(--margin);
  z-index: 2;
  background: none;
  border: none;
  color: inherit;
  cursor: pointer;
  padding: 0;
  transition: opacity var(--t-base) var(--ease);
}
.lightbox-close:hover { opacity: 0.6; }

.lightbox-media {
  width: 56vw;
  height: 64vh;
  display: flex;
  align-items: center;
  justify-content: center;
}

.lightbox-img {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  object-fit: contain;
  display: block;
  cursor: default;
}

.lightbox-video {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  display: block;
  cursor: default;
}

.lightbox-caption {
  position: absolute;
  left: var(--margin);
  right: var(--margin);
  width: 100%;
  bottom: var(--margin);
  z-index: 2;
  display: flex;
  flex-direction: column;
  gap: 0.222em;
  pointer-events: none;
}

/* Archive: split layout — image left, details right */
.lightbox.is-split {
  gap: 5vw;
  padding: 0 6vw;
}
.lightbox.is-split .lightbox-media {
  width: auto;
  max-width: 55vw;
}
.lightbox.is-split .lightbox-caption {
  display: none;
}
.lightbox-info {
  flex: 0 0 12.222em;
  display: flex;
  flex-direction: column;
  gap: 0.333em;
  cursor: default;
  align-self: center;
}
.lightbox:not(.is-split) .lightbox-info {
  display: none;
}
.lightbox-info-client {
  /* Was 500. One weight everywhere now. */
}
.lightbox-info-title {
  color: var(--c-ink-soft);
}
.lightbox-info-year {
  color: var(--c-ink-soft);
}
.lightbox-info-details {
  margin-top: 0.333em;
  line-height: 1.55;
  color: var(--c-ink-soft);
}

/* ---------- 404 ---------- */

#main:has(.not-found) {
  display: flex;
  flex-direction: column;
}

.not-found {
  flex: 1;
  padding: var(--gap) var(--margin);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: var(--margin);
}

.not-found-ascii {
  line-height: 1.25;
  white-space: pre;
  margin: 0;
  user-select: none;
}

.not-found-message {
}

/* ---------- site footer ----------
   Single bar: Newsletter toggle (left) swaps in-place for the signup form;
   email address sits on the right. */

.site-footer {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  padding: var(--gap) var(--margin);
  padding-bottom: max(var(--gap), calc(env(safe-area-inset-bottom) + var(--gap)));
}

.footer-left {
  display: flex;
  align-items: baseline;
}

/* newsletter-wrap is the height anchor — toggle button stays in flow always.
   Form and success float absolutely over it so the footer height never shifts. */
.newsletter-wrap {
  position: relative;
}

.newsletter-form {
  display: flex;
  align-items: baseline;
  gap: 0.556em;
  position: absolute;
  top: 0;
  left: 0;
  opacity: 0;
  pointer-events: none;
  white-space: nowrap;
}

.newsletter-input {
  background: none;
  border: none;
  border-bottom: 1px solid var(--c-ink);
  border-radius: 0;
  padding: 0;
  margin-bottom: 0.167em; /* visual gap between text and underline */
  line-height: inherit;
  color: var(--c-ink);
  width: 11.111em;
  outline: none;
}
.newsletter-input::placeholder { color: var(--c-ink-soft); }

.newsletter-submit {
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
  color: var(--c-ink);
  transition: opacity var(--t-base) var(--ease);
}
.newsletter-submit:hover { opacity: 0.6; }

.newsletter-success {
  margin: 0;
  color: var(--c-ink-soft);
  position: absolute;
  top: 0;
  left: 0;
  opacity: 0;
  pointer-events: none;
  white-space: nowrap;
}

/* open states — class-toggled by JS */
.newsletter-form.is-open,
.newsletter-success.is-open {
  opacity: 1;
  pointer-events: auto;
}
.footer-toggle.is-hidden {
  /* visibility:hidden is instant — no opacity transition fires.
     opacity:0 would use the before-state transition no matter what. */
  visibility: hidden;
  pointer-events: none;
}

.footer-email {
  color: var(--c-ink);
  transition: opacity var(--t-base) var(--ease);
}
.footer-email:hover { opacity: 0.6; }

.footer-toggle {
  background: none;
  border: none;
  padding: 0;
  cursor: pointer;
  color: var(--c-ink);
  transition: opacity var(--t-base) var(--ease);
}
.footer-toggle:hover { opacity: 0.6; }


/* ---------- masthead right, filters left ----------
   The nav goes back to the far right edge and the filters sit together at the
   left margin. Both are what the base flex rules already do, so what was here
   was a grid that put everything at the half of the window instead — removed
   rather than fought. It also styled .project-topbar, which no longer exists. */
@media (min-width: 701px) {
  /* Both controls together at the left, in the order they read: which set, then
     what to show. space-between would push the second one to the far edge. */
  .filterbar .wrap { justify-content: flex-start; gap: var(--margin); }
  /* Left over from when the flex row was how this got pushed right. An auto
     margin eats the free space and pins the item to the end whatever
     justify-content says. */
  .filterbar .view-dropdown { margin-left: 0; }
}

/* ---------- next project ----------
   The last thing on a project page: the whole bar is the link, with the next
   project's name and an arrow centred in it.

   In normal flow and nothing else — no fixed positioning, no transform on the
   page, no pushing. Every version of this that misbehaved was pinned to the
   viewport, which meant it was permanently on screen and permanently contending
   with the sticky masthead, the fixed backdrop column, or a project short enough
   that it never scrolled away. */

/* Makes #main a column so the shell's margin-top: auto has something to push
   against. WITHOUT THIS THE AUTO MARGIN IS SIMPLY ZERO — auto margins only
   absorb free space inside a flex container, so in a block layout the bar sits
   directly under the copy and the leftover height opens up beneath it. That is
   the bar stranded halfway down a short project. */
body[data-page="project"] #main {
  display: flex;
  flex-direction: column;
  /* THE WASH IS THE PAGE, not the closing field. On the field it stopped at that
     box's top edge, which drew a hard horizontal line across the window and made
     the colour into a panel arriving from below. #main holds everything, so the
     colour has no edge to give itself away.

     Painted here rather than on a fixed sheet over the viewport for the reason
     given on .page-shell: macOS Safari tints its own toolbar from a fixed or
     sticky element's background-color, so a full-viewport coloured sheet turns
     the browser chrome the same colour. Backgrounds on ordinary in-flow elements
     are never sampled.

     --dark is written by main.js and runs 0 to 1; --wash is the next project's
     own colour, the one the listing paints when you hover its thumbnail. Mixed
     rather than laid over at partial opacity, so the colour arrives at full
     strength instead of as a tint of itself.

     No transition: the value already moves smoothly because scrolling does, and
     easing on top would run the colour on a second clock and leave it trailing
     the scroll by a beat. */
  background-color: color-mix(
    in srgb,
    var(--wash, var(--c-smoke)) calc(var(--dark, 0) * 90%),
    var(--c-paper)
  );
}

/* Holds the bar's height and clips it, so the bar can start below its own slot
   and rise into place without anything else on the page moving. margin-top:auto
   drops it to the foot of #main, so on a project short enough to fit the window
   it lands on the bottom edge rather than stranding itself halfway down. */
/* A SCREEN OF NOTHING, and that is the whole device. The project's last image
   ends and the page carries on into an empty field the height of the window;
   the next project's name fades up out of it as you travel through. There is no
   control here — no bar, no pill, no border — because the emptiness is the
   transition and the type is the only thing in it.

   svh, not vh. On a phone vh is the tallest the window ever gets, so a field set
   to 100vh is always taller than the screen showing it and the name can never
   quite finish arriving. svh is the smallest state, which is the one that is
   actually on screen while you scroll. */
.next-index-shell {
  margin-top: auto;
  min-height: 100svh;
  display: flex;
  align-items: center;
  /* Centred in the field. The whole point of the emptiness is that there is no
     layout in it, and a block on the left margin makes the right two thirds
     read as space left over rather than as space. */
  justify-content: center;
  padding: var(--margin);
  position: relative;
  /* Above .project-backdrop, which is fixed at z-index 0 and runs the full
     height of the window — without this the grey column paints over the bar. */
  z-index: 1;
}

/* Set on the left margin like every other line on the page, not centred. A
   centred line would be a composition, which is a way of making the emptiness
   into a layout; on the margin the name is simply where the reading has been
   all along, and the field around it stays empty rather than becoming a frame.

   --enter runs 0 to 1 and is written by main.js from the field's position in the
   window. No transition on either property: the value already moves smoothly
   because scrolling does, and a transition on top of it would lag the type
   behind the scroll and put the two on different clocks. Defaults to 1, so with
   no JS the name is simply there. */
.next-index {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  gap: 0.8em;
  /* The colour is written straight onto this element by main.js, one rgb() per
     frame, interpolated ink to paper on the same ramp as the ground. It was a
     color-mix() here reading two custom properties through a calc() percentage,
     which did not take on staging — and the value that mechanism fails to is
     ink, which is indistinguishable from it never having been asked to change.
     One inherited variable feeding a calc feeding a mix is three places for a
     silent failure; setting the number is none.

     This declaration is the resting state and the no-JS state, nothing more. */
  color: var(--c-ink);
  text-decoration: none;
  opacity: var(--enter);
  transform: translateY(calc((1 - var(--enter)) * 0.5em));
  will-change: opacity, transform;
}
.next-index-line {
  display: inline-flex;
  align-items: center;
  gap: 0.6em;
}

/* The cover at the ratio it is drawn at on the listing, so the picture you are
   about to open is the same shape you would have clicked there.

   The width is the smaller of two figures, not a single one. 30vw alone is fine
   for a landscape cover and hopeless for a tall one — a 3:4 at 30vw on a wide
   window runs past the bottom of the field it is supposed to sit inside. The
   second figure is the width a 55svh-tall picture of this ratio would have, so
   whichever constraint binds first is the one that applies and the box is never
   taller than the screen it arrives on.

   The ratio is a bare number for that reason: calc can multiply a length by a
   scalar, but not by 600 / 400. */
.next-index-thumb {
  display: block;
  width: min(30vw, calc(55svh * var(--next-ar, 1.4)));
  aspect-ratio: var(--next-ar, 1.4);
  object-fit: cover;
  background: var(--c-placeholder);
}

/* No fade of its own any more. The name used to come up separately because the
   bar it sat in was opaque and arrived first; the pill fades as one object, and
   fading a child of something already fading only compounds the two. */
.next-index-name { min-width: 0; }

/* The next-project arrow is gone — the bar is the name and nothing else now.
   Its rule and the CSS-drawn stem-and-arms that preceded it are in git. */
.next-index:hover { opacity: calc(var(--enter) * 0.65); }

@media (prefers-reduced-motion: reduce) {
  /* main.js never writes --enter in this case, so the default of 1 already
     holds. Stated anyway, because the default living in another file is not
     something the next person reading this should have to know. */
  .next-index { --enter: 1; }
}

/* ---------- project page: the sticky info block ----------
   Replaces the half-page details panel. Client, title, then the copy clamped to
   two lines with a Read more that lets the rest out, pinned at the top left over
   the artwork.

   position: sticky, not fixed. Fixed would take it out of flow, and the whole
   point of Read more is that opening it pushes the pictures down rather than
   covering them — which only happens if the block still occupies space. */
/* The block travels only as far as this goes. No height, no overflow, no
   position of its own — a sticky element is held inside its parent's box and
   that is the whole mechanism, so all this has to do is end where the artwork
   ends. Anything that establishes a scroll container or a containing block here
   would break the stickiness rather than bound it. */
.project-stage { position: relative; }

/**
 * NOT STICKY. It scrolls away with everything else.
 *
 * It travelled down the artwork while it held the copy, because the copy was
 * the thing you wanted to keep reachable. The copy is a panel now, reachable
 * from a button, so the block is a name, a title and a button — a caption at
 * the top of a page, and captions do not follow you.
 *
 * Dropping it also removes a stacking context that was quietly in the way:
 * position: sticky always makes one, so nothing inside this block could paint
 * above the masthead however high its z-index, which is why the panel kept
 * being trapped under the wordmark.
 */
body[data-page="project"] .project-info {
  /**
   * NO POSITION AND NO Z-INDEX, and both halves matter.
   *
   * Dropping sticky was not enough: position: relative with a z-index still
   * forms a stacking context, so the panel stayed trapped at this block's 6 and
   * the navigation kept painting over it at 1200. The block has nothing to
   * stack against now the copy has left it, so it can simply be in flow.
   */
  /* No height of its own beyond its contents, so the artwork starts directly
     underneath rather than after a reserved band. */
  /* NO PADDING. It inset the details by a margin the pictures beside them do
     not have, so the client name sat 24px below the top of the artwork it is
     supposed to line up with. The stage's gap and the page margin already
     place this column; a second inset only broke the top line. */
  padding: 0;
  pointer-events: none;
}
.project-info-inner {
  max-width: 34em;
  pointer-events: auto;
}

.project-info-client { margin: 0; }
.project-info-title { margin: 0; color: var(--c-ink-soft); }

/* A record is italicised the way a book is, and the body copy already does it —
   "Nevertake is a re-recording of Never Take Friendship Personal" sets both in
   italic, then the same title sits roman in the line above it. Music packaging
   is the only category whose title is the name of a released work rather than a
   description of a job, so it is the only one this applies to. Scoped to this
   block; the listing rows and archive cards are a different argument. */
.project-info[data-categories~="music-packaging"] .project-info-title {
  font-style: italic;
}

/* The space between the name and the copy, and the thing that actually moves.
   max-height is animated from the clamped height to the full one by main.js,
   which is how the details panel worked — the same easing and the same duration,
   so opening the copy feels like the same gesture it always did. The pictures
   being pushed down comes for free: the block is in normal flow, so everything
   under it travels with the height. */
.project-info-copy {
  margin-top: 1.5em;
  overflow: hidden;
  transition: max-height var(--t-slow) var(--ease-panel);
}

/* TWO LINES AND AN ELLIPSIS. line-clamp needs a block container with a fixed
   line count, and it only works on the element that directly holds the text —
   so the clamp goes on a wrapper that contains the whole body, and the body's
   own paragraphs are folded inline. Once open, the clamp is lifted and the
   paragraphs go back to being paragraphs. */
/* TWO LINES AND AN ELLIPSIS. The paragraphs stay blocks in both states — they
   used to be folded inline while clamped, which meant opening reflowed the text
   as well as changing the height, and no height animation can hide a reflow.
   -webkit-line-clamp handles a box with block children, so the only difference
   between open and closed is the clamp itself. */
.project-info-clamp {
  /* NOT CLAMPED ANY MORE. Two lines was right when the details were a band
     across the top of the page and the copy had to be got out of the way. A
     column has the height for all of it, and the button that used to unclamp
     this now folds the whole column away instead. */
}
.project-info-clamp > * { margin: 0 0 1em; }
.project-info-clamp > :last-child { margin-bottom: 0; }

/* THE GAP ABOVE THE COPY WAS DOUBLE, and this is why. The rule above only
   reaches direct children, but the body arrives as a wrapper div of paragraphs
   — so the wrapper lost its top margin and the first paragraph inside it kept
   its own 1em, which stacked on the 1.5em above to make 2.5em. The space under
   the copy has no such passenger, so the two never matched: 47.5px against
   28.5px, measured on Clever.

   Reaching one level in is enough. Deeper nesting than a wrapper of paragraphs
   is not something the body blocks produce. */
.project-info-clamp > :first-child,
.project-info-clamp > :first-child > :first-child { margin-top: 0; }

.project-info.is-open .project-info-clamp {
  display: block;
  overflow: visible;
}

/**
 * THE COPY OPENS AS A PANEL, ON DESKTOP ONLY.
 *
 * Closed, the block is a name, a title and a button; the copy is not in the
 * flow at all, so nothing is sticky that should not be and nothing pushes the
 * artwork down. Open, the same element becomes a fixed panel over the left half
 * of the window with the artwork blurred behind it.
 *
 * NO SECOND COPY OF THE COPY. The panel is .project-info-copy itself, moved by
 * CSS rather than re-parented by script. One element means mobile keeps the
 * inline behaviour below without a duplicate in the markup, and nothing has to
 * be moved back and forth as the window is resized.
 *
 * 93% WHITE OVER A 3px BLUR, which is the balance the reference uses and the
 * opposite of the obvious one. A heavy blur under a light wash turns a
 * photograph to grey soup; a heavy wash under a slight blur reads as paper laid
 * over the picture, and you can still tell what is underneath.
 */

/* Only ever a panel header. Closed, and on mobile where the copy expands in
   place, there is no panel for it to head. */
.project-panel-head { display: none; }

/* The button is the details toggle, not a new control — it keeps
   .filter-dropdown-trigger, .about-toggle-label and .about-toggle-icon, so the
   type, the label cross-fade and the plus turning into an × all come from rules
   that already existed. Only three things differ: it opens on its own wrapper
   rather than a body class, now the panel it used to drive is gone; the label
   reads left instead of right; and it sits under the copy.

   The plus leads, like every other indicator on the site — the nav dot, the
   filter triangle and the archive dot all sit ahead of their word. The trigger's
   own gap is 0.444em, which is the same figure those carry as margin-right, so
   the spacing needed nothing doing. */
/* Even 1.5em above and below. */
.project-info .about-dropdown {
  margin-top: 1.5em;
  margin-bottom: 1.5em;
}
/* Hidden when the copy fits in two lines anyway — set by main.js, which is the
   only thing that can know whether anything is actually being withheld. */
.project-info .about-dropdown[hidden] { display: none; }

/* The toggle is drawn as a pill on project pages, and only here. Everywhere else
   a filter trigger is bare type with a mark in front of it, because those sit in
   a row of their own and read as a set. This one sits alone under a paragraph,
   where bare type reads as another line of the paragraph rather than as
   something to press — the outline is what says it is a control.

   currentColor throughout, so the rule survives the difference blend above: the
   whole control resolves to #fff in the source and inverts per pixel with the
   type it sits under, rather than holding a fixed grey that would show through
   as a hard edge over a photograph.

   MATCHED TO THE DELIVERY PORTAL'S PILL, which is 0.6em top and bottom against
   1.2em either side. This was 0.15em against 0.6em — a quarter of the vertical
   room and half the horizontal — which drew the outline tight around the word
   rather than giving it a shape of its own.

   MEASURED AGAINST THE LABEL, NOT THE BUTTON. The first attempt at that match
   wrote the ratio as plain ems, which was wrong: em on this element resolves
   against the button's own font-size, and the label inside is --fz-small, 0.66
   of it. So the pad was built around type nobody can see, and came out at 0.88
   of the visible word instead of 0.6 — a pill noticeably taller than the one it
   was copying. Scaling by --fz-small ties the space to the word it surrounds,
   and keeps doing so if that size ever changes.

   The vertical figures still differ by a hundredth. Caps and lowercase do not
   sit centred in their own line box — there is more space under the baseline
   than over the cap — so an even pad lands the word high. */
/* ======================================================================
   THE DETAILS AS A COLUMN, AND THE CONTROL THAT FOLDS IT AWAY.

   The details were a full-width band above the pictures. They are a 30% column
   beside them now, plain white, open on load — and the toggle gives that width
   back to the artwork rather than revealing anything.
   ====================================================================== */
@media (min-width: 701px) {
  /* .project-stage held the band and the body stacked. One flex row and they
     are side by side; nothing about either child had to change to allow it. */
  /* A GRID, BECAUSE FLEX CANNOT SAY "THE BOTTOM OF THE SHORT COLUMN".
     The icon has to sit on the last line of the details and at the page's right
     inset. In a flex row those fight: align-self: last baseline aligns against
     the row's baseline group, which the pictures dominate, so the icon landed
     1,400px down at the bottom of the artwork. Measured, not assumed.

     Two rows solve it. The details occupy row 1, so row 1 is exactly as tall as
     they are. The pictures span 1 / -1, so their height lands in row 2 and never
     inflates row 1. The icon sits in row 1 of a third column and aligns to its
     end — which is the details' bottom, wherever that falls. */
  .project-stage {
    /* The gap animates with the track. Collapsed it goes to zero, and left out
       of the transition it would snap there while the column was still easing —
       a 24px jolt in the pictures halfway through an otherwise smooth fold.

       --t-base, NOT --t-slow. This is a layout animation: every frame re-lays
       out the whole artwork column — the video, every grid row, every image —
       and there is no way to hand any of that to the compositor. The only
       control over how rough it looks is how many frames of it there are. At
       0.42s it was long enough to watch stutter; at 0.25s the same work reads
       as decisive. Shortening was the fix, not easing. */
    transition:
      grid-template-columns var(--t-base) var(--ease-panel),
      column-gap var(--t-base) var(--ease-panel);
    display: grid;
    grid-template-columns: 40% 1fr;
    grid-template-rows: auto 1fr;

    /* TWO DIFFERENT GAPS, because they are separating two different things.
       The column gap is the gutter between the details and the pictures and is
       the page inset, like every other gutter on the site. The row gap is the
       space under the project's name before the work starts, and reads as a
       break in the page rather than a gutter — so it is twice that. Written as
       one `gap` they were forced equal and the name sat too close to the
       artwork. */
    row-gap: calc(var(--margin) * 2);
    column-gap: var(--margin);
    align-items: start;

    /* THE PAGE MARGIN LIVES HERE NOW. The details column used to carry it as
       its own padding, which is why its text sat 24px below and inside the
       pictures it is meant to line up with. On the stage it insets both columns
       equally: the client name starts on the same left line as the wordmark,
       and the pictures stop on the same right line as the nav. */
    /* THE PROJECT'S NAME STARTS WHERE THE LISTING'S FILTERS DO. Both pages get
       24px from #main; the listing's work section then adds a second --margin
       before its filter row and this stage was adding nothing, so a project
       opened 24px higher than the page you came from. Measured on both: filter
       row 123.55, project name 99.55, and this closes it. */
    padding-top: var(--margin);
    padding-inline: var(--margin);
  }
  /* THE NAME IS A BAND ACROSS THE TOP; the work starts under it.
     Row 1 is the project's name and the icon. Row 2 is the description and the
     pictures. So the pictures no longer begin level with the client name — they
     begin level with the copy, and the name sits above the whole page like a
     title rather than beside the artwork like a caption.

     SUBGRID IS WHAT MAKES THIS ONE COLUMN AGAIN. The details span both rows, so
     they remain a single element: still sticky as one piece, still the thing
     that collapses, still carrying its own selectors. Their children adopt the
     stage's rows instead of rows of their own, which is the only way row 1 can
     be sized by the name while the column that contains the name spans past it.
     Splitting the head out to the stage would have got the same picture and cost
     the sticky column, which was the reason not to. */
  body[data-page="project"] .project-info {
    grid-column: 1;
    grid-row: 1 / -1;
    display: grid;
    grid-template-rows: subgrid;

    /* A PINCH OFF THE RIGHT, INSIDE THE COLUMN. The track went to 40%, which is
       about 620px on a laptop — a 75-character line, wider than Georgia wants to
       be read at. This holds the copy back from the column's own right edge
       without moving the pictures a pixel: the gutter between the two is still
       the page margin, and the artwork starts exactly where the track ends.

       Inline-axis only, so it does not touch the subgridded rows — the name and
       the pictures stay on the same top line. */
    padding-right: var(--gap);

    /* Folded it goes to zero, and it has to ease there with the track or the
       copy would be held off an edge that is no longer beside it. */
    transition: padding-right var(--t-base) var(--ease-panel);
  }
  .project-info-inner { display: contents; }
  .project-head {
    grid-row: 1;
    pointer-events: auto;

    /* THE NAME IS SIZED TO ITSELF, NOT TO THE COLUMN, and this is what stops it
       jumping. The track animates from 0 to 30%, so a head that took its width
       from the track was being re-wrapped on every frame of the opening: at the
       first frame the column is a few pixels wide and "Clever Supply / Enjoy The
       Carry" breaks to one word per line, five lines tall, then collapses back
       to two as the column arrives. Caught it in the recording — frame 18.

       max-content makes the width the longest line's width, which does not
       change when the track does, so there is no reflow to watch. It overhangs
       the empty track while folded, which is free: row 1 holds nothing else but
       the icon at the far right, and .project-info does not clip.

       The measure still caps it, so a long title wraps — but at a fixed 34em
       rather than at whatever width the animation is passing through. */
    width: max-content;
    max-width: var(--info-measure);
  }
  .project-body { grid-column: 2; grid-row: 2; }

  /* THE ICON SHARES THE PICTURES' COLUMN. It had a third column of its own,
     which is exactly why it floated: a column beside the artwork can only ever
     put it in the gutter between the artwork and the window, aligned to
     nothing. In the pictures' column it ends at their right edge — which is the
     page inset — and row 1 ends at the name's last line, which is now the title
     rather than the bottom of a column of copy. Right inset and bottom line,
     both, without a track of its own. */
  .project-detail-toggle {
    grid-column: 2;
    grid-row: 1;
    justify-self: end;
    align-self: end;

    /* LIFTED 8px OFF THE TITLE'S LINE. align-self: end sits the icon's box on
       the bottom of row 1, which is the bottom of the title's line box —
       descender space included, so the mark reads low against lettering that
       has no descender in it. This is the same correction --mark-nudge makes
       for the caret and the next-project arrow.

       margin rather than translate, so it is a real position rather than a
       paint offset — and it cannot be wiped out by the transform that turns the
       icon when the details fold. Row 1 is 55px against an 11px mark, so the lift
       has room and the row does not grow.

       NO COMPENSATION, because the mark has no padding to compensate for. A
       pair of negative margins stood here for move.svg, which was drawn inset
       in its artboard — the bar stopped 3.22 short of the right edge and the ink
       5.26 above the bottom, and the margins took that back out. The solid
       square fills its box, so those numbers were pulling it 3.22px past the
       page margin and cutting the lift to 2.74px. Measured on the deployed page
       before removing them. */
    margin-bottom: 8px;
  }

  body[data-page="project"] .project-info {
    /* 30% of the page, and it does not grow or shrink from there — a details
       column that resized itself against the pictures would put the copy on a
       different measure on every project. */

    /* THE STICKY MOVED TO THE COPY. It was here, and here it can no longer
       work: this element spans both rows now, so it is as tall as the artwork,
       and an element the full height of its scroll container has nothing left
       to stick within — measured, it scrolled away with the page. The copy is
       the part that was meant to hold anyway, and it is short, so the offset
       lives on it instead. See .project-info-copy below.

       The name does not stick, and that is deliberate: it and the icon are one
       object now, and pinning one of the two would separate them. */
    position: static;

    /* The band was pointer-events: none so the artwork underneath stayed
       reachable. Nothing is underneath it now; it is a column of its own and
       has to take its own clicks. */
    pointer-events: auto;

    /* NO BACKGROUND. It had var(--c-paper), which is the page colour and so
       invisible — right up to the point the page changes colour under it. The
       work listing washes the shell to a project's own hue on hover, and a
       sticky column carrying its own white would then sit on that wash as a
       hard-edged white block travelling down the page.

       Transparent means the column is only ever type on whatever the page is,
       which is what "nothing special" asks for and also what survives a
       background it does not control. */

    /* The width and the fold are the same animation, so the pictures widen at
       exactly the rate the details narrow. */
    /* The track is what animates now, on .project-stage — a grid column cannot
       be transitioned from the item's side. */
    min-width: 0;
  }

  .project-info-copy {
    grid-row: 2;
    max-width: var(--info-measure);
    pointer-events: auto;

    /* SCROLLS, THEN STOPS. sticky is not fixed: the copy travels with the page
       until its top meets the offset, then holds while the pictures keep going.
       align-self: start is what allows it — stretched to the row it would be as
       tall as the artwork and have nothing to stick within, which is exactly
       how the sticky on the column above failed. */
    align-self: start;
    position: sticky;
    top: var(--margin);

    /* IT WAITS FOR THE COLUMN, THEN FADES IN. The copy's measure is the track,
       and the track animates from nothing — so while it is moving this
       paragraph is being re-wrapped every frame and the words visibly jump
       around. Nothing can be done about the reflow itself; a paragraph in a box
       that is changing width reflows. What can be done is not showing it.

       The delay is the fold's own duration, so the fade does not begin until
       the column has stopped moving and the text has reached its final measure.
       It then appears already set, rather than painting in while it settles.
       Closing has no delay — see the collapsed rule. */
    transition: opacity var(--t-base) var(--ease-panel) var(--t-base);

    /* THE SPACE UNDER THE NAME IS THE ROW GAP NOW, not this margin. Left in
       place it would stack on top of the gap and push the copy below the
       pictures it is meant to line up with. Zeroed here rather than deleted,
       because below 701px there is no grid and no row gap, and this margin is
       still the only thing holding the name off the copy. */
    margin-top: 0;
  }

  /* FOLDED. Zero width rather than display: none, so there is something to
     animate and the column comes back the way it left. overflow: hidden stops
     the copy spilling across the pictures on the way. */
  /* FOLDED: THE COLUMN GIVES UP ITS WIDTH BUT NOT ITS NAME.
     The client and title stay, and the pictures run edge to edge underneath
     them. That is why the width goes to zero while overflow stays visible —
     the column occupies no space in the row, so .project-body takes all of it,
     and what is left of the column paints over the artwork rather than being
     clipped away with it.

     The copy, year and credit are the part that actually folds. */
  /* The details track collapses to nothing and the pictures take the width.
     overflow: visible is what leaves the name painted over the artwork rather
     than clipped away with the column it no longer fits in. */
  .project-stage.is-collapsed {
    grid-template-columns: 0 1fr;

    /* AND THE GUTTER GOES TOO. Zeroing the track is not enough: the column gap
       still sits between the empty track and the pictures, so the artwork
       started 48px in — the page margin plus a gutter with nothing on the far
       side of it. Measured: canvas left 48 against a 24 margin. A gutter
       separates two things, and collapsed there is only one. */
    column-gap: 0;
  }
  body[data-page="project"] .project-stage.is-collapsed .project-info {
    overflow: visible;
    z-index: 3;

    /* Padding does not shrink with the track it sits in, so folded it would be
       48px of nothing hanging off a zero-width column. Nothing paints there, but
       it animates to zero with the rest rather than being left behind. */
    padding-right: 0;
  }
  /* IT FADES, IT DOES NOT VANISH. This was display: none, which cannot be
     animated — the instant the button was pressed a paragraph of copy blinked
     out of existence and the column then spent 400ms closing on nothing. That
     pop, at both ends, is most of what made the gesture feel clunky.

     Fading instead, fast and with no delay, so the text is gone before the
     column has narrowed enough to rewrap it. The box still collapses to nothing
     with the track, and .project-info-copy carries overflow: hidden, so the
     faded text is clipped rather than sitting invisibly over the pictures. */
  .project-stage.is-collapsed .project-info-copy {
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--t-quick) var(--ease-panel);
  }
  /* Nothing to wrap against once the column has no width, so the name would
     break to one character per line without this. */
  .project-stage.is-collapsed .project-info-client,
  .project-stage.is-collapsed .project-info-title { white-space: nowrap; }

  /* The pictures take whatever the details are not using. min-width: 0 because
     a flex item defaults to its content's width and images would refuse to
     narrow without it. */
  .project-body { min-width: 0; }
}

/* THE CONTROL. It travels with the project's name and does not stick.
   It was sticky, which made sense while the name was beside the artwork and the
   two scrolled together. The name is a band across the top of the page now, so
   a sticky icon would detach from it on the first scroll and hang alone beside
   the pictures — which is precisely the floating this layout was meant to end.
   Anchored to the title's line, it is never anywhere the title is not. */
.project-detail-toggle {
  /* A THIRD ITEM IN THE ROW, not a child of either column — which is what puts
     it at the right edge of the page rather than the right edge of the details.
     .project-body takes the remaining space, so this lands hard against the
     window margin whether the details are open or folded.

     Not inside .project-body, which is overflow: hidden — sticky inside a
     clipped ancestor treats that ancestor as its scroll container, finds no
     scrolling there and never sticks. That is why it sits here and not with the
     pictures it aligns to. */
  position: relative;
  z-index: 2;

  /* BOTTOM-LINED TO THE DETAILS, not top-aligned to the row. The details column
     and the pictures are wildly different heights, so flex-start put the icon at
     the top of a very tall row and nowhere near the thing it belongs to. Last
     baseline sits it on the final line of the details — the same alignment the
     button had when it lived in that column. */
  /* Inset from the window edge by the page margin, like everything else. It was
     flush against the glass. */
  display: inline-flex;
  align-items: center;
  background: transparent;
  border: none;
  padding: 0;

  /* Over the artwork now, so it is differenced against whatever is behind it —
     the same treatment .video-toggle takes, and for the same reason: a fixed
     ink would vanish on a dark picture and a fixed white on a pale one. */
  color: #fff;
  mix-blend-mode: difference;
  cursor: pointer;
  pointer-events: auto;
}
.project-detail-toggle:hover { opacity: var(--hover-fade); }

/* THE DETAILS CONTROL, in the two solid shapes. Every word that stood here
   before described marks built out of 1px CSS boxes, then SVG strokes, then a
   Font Awesome outline. None of it survives a solid shape: there is no weight to
   match, no stem-to-arm ratio, no hairline that has to agree with a caret. */
.project-detail-icon {
  flex: none;
  display: block;
  width: var(--mark-toggle);
  height: var(--mark-toggle);
}

/* ONLY THE LEAD BAR MOVES. Scaled to 46 it meets the second bar exactly, so the
   gap closes and the pair becomes one solid block with no seam — one transform
   on one shape rather than two elements swapped or cross-faded.

   transform-box: fill-box is what makes the origin mean the rect's own left
   edge; without it an SVG transform-origin resolves against the viewport and
   the bar would scale from the wrong place. */
.project-detail-icon .mark-split-lead {
  transform-box: fill-box;
  transform-origin: left center;
  transition: transform var(--t-base) var(--ease-panel);
}
.project-stage.is-collapsed .project-detail-icon .mark-split-lead {
  /* 51.91 / 32.73, not 50.91 / 32.73. Scaling to exactly meet the second bar
     makes the two abut, and two shapes meeting on a fractional device pixel
     leave an antialiased hairline down the middle of what is meant to read as
     one solid block — visible at 7x, and visible on a 1x screen at size. One
     unit of overlap removes it.

     The overlap is internal: the lead runs 0 to 51.91 and the second 50.91 to
     100, so the folded mark is still exactly 0 to 100 and still a true square. */
  transform: scaleX(1.5861);
}




/* Below the breakpoint there is no row to take width from — the details are
   simply above the pictures — so folding is a hide rather than a narrowing. The
   button would otherwise be inert on a phone, which is worse than either state. */
@media (max-width: 700px) {
  .project-stage.is-collapsed .project-info { display: none; }
}

/* THE PLUS SITS A PIXEL LOW OF THE LABEL'S CENTRE, deliberately.
   align-items: center centres the icon's box on the line box, but a line box is
   taller than the type in it and hangs lower — nothing in "Project Details"
   fills the descender space under the baseline. So a mark centred by
   measurement reads high next to the letters.

   Written through --mark-nudge, the knob the icon already uses, rather than a
   new transform: the icon's transform belongs to the rotate that turns the plus
   into an x when the panel opens, and a second transform would overwrite it.
   translate is its own property and composes with that rotate rather than
   fighting it. */


.project-info .filter-dropdown-trigger {
  /* A TEXT LINK, NOT A PILL. The border, the 30em radius and the padding are
     gone; the base .filter-dropdown-trigger rule underneath is already
     borderless with no padding, so this only has to stop overriding it.

     The gap stays: it is the space between the plus and the label, which is
     spacing inside a line of type rather than chrome around it. */
  gap: 0.35em;
  background: transparent;
}

/* Same inversion the homepage type uses: decided per pixel against whatever is
   behind it, so it reads on a black cover and a white one without either being
   chosen in advance. White is the input, not a colour anyone sees. */
@supports (mix-blend-mode: difference) {
  /* THE PAGE HAS TO PAINT ITSELF FOR THE BLEND TO HAVE ANYTHING TO WORK AGAINST.
     The white on this site comes from <body>, and a body background is
     propagated to the page canvas — which is not part of any element's backdrop.
     Blending against nothing leaves the source colour untouched, so the type
     stayed white and vanished on a white page. The homepage never showed this
     because painted slides sit behind its type.

     So #main states the white instead. It is the nearest ancestor that both
     contains the info block and actually accepts a background — .page-shell
     refuses one even from an !important inline rule, measured on the live page,
     so whatever is going on there it is not a surface worth relying on. With
     #main painted the blend has a real backdrop: white on white resolves to
     black, and over a photograph it inverts per pixel as intended. */
  /* #main is painted above, and that is what gives this blend a backdrop — a
     background on <body> propagates to the page canvas and is not part of any
     element's backdrop, so blending against it leaves the source colour alone.
     Nothing to restate here; the colour just has to exist, and it does. */
  /* NO BLEND ON THE FILTERBAR, and the reason is worth keeping. Difference only
     reads as inversion against something near black or near white: white on a
     saturated mid-tone gives its complement, so over the red wash the filters
     came out teal while the nav beside them — which uses the data-spot-theme
     flip — was cleanly white. Two controls on one line, one of them a colour
     nobody chose.

     The project info block below can blend because it sits on paper and on
     photographs, where difference is either a clean flip or a per-pixel
     inversion of an image. The listing sits on one flat colour, which is exactly
     the case difference is worst at. The wash already carries a luminance test
     and a light/dark flip for the type; that is what the filters use. */
  /* NEITHER OF THESE ANY MORE — and together they were the black box.
     The details used to lie over the artwork, so they blended with difference
     and took white as the input that difference works from. They are a column
     on paper now: white type on a white column is invisible, and a white
     background differenced against a white page computes to black. That is the
     whole of it — a blend doing exactly what it was told, to a thing that had
     moved out from under the pictures.
  body[data-page="project"] .project-info { mix-blend-mode: difference; }
  body[data-page="project"] .project-info,
  body[data-page="project"] .project-info-title,
  body[data-page="project"] .project-info .filter-dropdown-trigger { color: #fff; } */
}

/* ---------- project page: the measure ----------
   Every block that puts a picture on the page sits centred at 80% of the window.
   Checking Full width in the Studio releases that one block to the edges. */
/* The canvas gives up its side padding on project pages, so 80% means 80% of the
   window and Full width actually reaches the edges. The measure is the frame's
   job now; a padded parent would have quietly made both of them smaller. */
body[data-page="project"] .project-canvas {
  padding-left: 0;
  padding-right: 0;
}
/* THE MEASURE IS THE COLUMN NOW.
   80% was right when the canvas was the whole window: it kept a picture off the
   edges. The pictures sit in their own column beside the details now, and that
   column already provides the inset — so 80% of it was a second margin inside
   the first. Measured on the deployed page: the canvas ran 24 to 1368 and the
   blocks 158 to 1234, leaving 194px of dead white on the right that nothing
   explained.

   Full width of the column here; .is-full still escapes to the window edges. */
.block-frame {
  width: 100%;
  margin-inline: auto;
}
.block-frame.is-full {
  width: 100%;
}

@media (max-width: 700px) {
  /* No room to give away on a phone, and nothing to centre against. */
  .block-frame,
  .block-frame.is-full { width: 100%; }
  body[data-page="project"] .project-info { position: static; }
}

/* ---------- the small-text cut ----------
   Every rule on the site that sets a size below 1em, gathered in one place and
   given the Micro face. Kept as a list rather than scattered through the
   stylesheet so the two are impossible to get out of step: if something new is
   set small, it belongs here, and if something here stops being small it should
   come out. The sizes themselves stay where they are — this only changes which
   cut draws them. */
.row-nda,
.slide-caption .slide-count,
.slide-loading,
.slideshow-counter,
.blk figcaption,
.about-meta,
.lightbox-info-year,
.lightbox-info-details,
.grid--archive .card-meta .title,
.grid--archive .card-meta-title {
  /* NO TRACKING EXCEPTION. This was -0.05em, closing up Helvetica Now Micro at
     caption size — both the cut and the size are gone, so the reason is too.
     These elements inherit the site's 0.01em like everything else. */
}
/* One exception, and it is not really small text doing the same job. NDA is
   three caps in a box at the smallest size on the site — caps have no
   ascenders or descenders to separate them, so they need the gap between the
   letters instead, and the box makes any crowding obvious. Set back to the
   figure it was drawn at. */
