@font-face {
  font-family:"Geist"; src:url("/fonts/geist-latin.woff2") format("woff2");
  font-weight:400 600; font-display:swap;
  unicode-range:U+0000-00FF, U+2014, U+00B7;
}
/* One weight, no axis — a static face, unlike the two variable families above. */
@font-face {
  font-family:"Architects Daughter"; src:url("/fonts/architects-daughter-latin.woff2") format("woff2");
  font-weight:400; font-display:swap;
  unicode-range:U+0000-00FF, U+2014, U+00B7;
}

/* ---- tokens ---- */
:root {
  --paper:#FAF9F7; --ink:#1A1A18; --prose:#3A3833; --muted:#57534E;
  --grey:#615B55; --faint:#6B655F; --label:#76706A; --rule:#E2DFDA;
  --field:#D6D2CB; --lb-ground:#0C0C0B; --lb-rule:#232320; --on-ink:#FFFFFF;
  /* The mat behind a photograph and the EXIF strip that sits flush on top of it.
     Its own token rather than --ink: --ink is the page's TEXT colour, and the two
     only happened to share a value. Pure black here would have dragged every
     paragraph on the site to #000 with it. */
  --mat:#000000;
  --sans:"Geist", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
  /* Genre descriptions only. The fallbacks are real handwriting faces rather than a
     bare `cursive`, which resolves to something different on every platform. */
  --hand:"Architects Daughter", "Segoe Script", "Bradley Hand", cursive;
  /* --row-gap is the vertical gutter of the photograph grid, --gap the horizontal
     one. They are deliberately unequal: the rows carry the mat's deep bottom band
     plus the frame number, so matching the two would read as tighter vertically
     than horizontally rather than even. */
  --gap:30px; --row-gap:86px; --edge:clamp(20px, 5vw, 48px);
  --hdr-edge:32px;
  /* The thumbnail rail's width. There is no --thumb-gap any more: the strip sits hard
     against --hdr-edge now that the genre filters have left the left-hand column, so
     there is nothing beside it for a gap to separate it from. */
  --thumb-w:80px;
  /* How far the active thumbnail swings right at the peak of the bulge. */
  --bulge-max:30px;
  /* Nudge for the photograph columns. Taken off the rail lane and handed to the
     opposite side rather than simply subtracted, so the grid moves without the
     measure narrowing — see .layout. */
  --grid-shift:20px;
  /* The mat around a photograph, deeper below than above so a mounted print does
     not look bottom-heavy. One set of values at every width: 640px is this site's only
     breakpoint, and trimming the desktop mount by the same 20px the phone got left the
     two identical, so the media-query override that used to sit here is gone rather
     than restating what :root already says.
     --mat-x is load-bearing: it is the 10px either side that takes the 952px column
     down to the 932px photograph SIZES advertises in lib/render.js and src/grid.js.
     Changing it desynchronises the srcset. */
  --mat-x:10px; --mat-t:15px; --mat-b:40px;
  /* Menu overlay. The page ink is unusable here — #1A1A18 on #000000 is 1.20:1 —
     so the two states invert: white at 21:1, and a warm grey at 6.13:1 that clears
     AA even at the normal-text bar, with 3.43:1 separating it from the active link. */
  --overlay:#000000; --on-overlay:#FFFFFF; --on-overlay-muted:#8F8A83;
  /* The width of the single photograph column. The grid used to divide this into two
     580px slots; it is now one, and 952 is that full-width column taken down by 20%
     (1190 x 0.8). Less the mat's 10px either side, the photograph is 932.
     Keep SIZES in lib/render.js AND src/grid.js in step with this — it is the hint the
     browser picks a variant from, and a stale one silently serves the wrong file. */
  --measure:952px;
}

*, *::before, *::after { box-sizing:border-box; }
html { -webkit-text-size-adjust:100%; }
body {
  margin:0; background:var(--paper); color:var(--ink);
  font-family:var(--sans); font-size:16px; line-height:1.5;
  /* Column flex so <main> can be told to absorb whatever height is left over. On the
     index this changes nothing — main is already taller than the viewport. On the short
     pages it gives .contact and .prose a parent whose height IS the space the header
     leaves, so centring inside them needs no guess at how tall that chrome is. */
  min-height:100vh;
  display:flex; flex-direction:column;
}
body > main { flex:1 0 auto; }
img { max-width:100%; }
a { color:inherit; text-decoration:none; }

/* main follows the header out to the page edge, so the filter rail can sit flush with
   the wordmark. The grid keeps its own measure via .layout__main below. */
body > main { padding-inline:var(--hdr-edge); }

/* ---- first-load reveal ---- */
/* A white screen that draws the mark once, then gets out of the way.
   The artwork is a FILLED outline, not a stroked line, so there is nothing to run a
   stroke-dashoffset along. The reveal is a mask instead: .loader__trace is a centreline
   traced through the middle of the spiral band, stroked wide enough to cover it, and
   dashed back to nothing. Where the stroke paints, the black shape shows — so growing
   the stroke walks the shape open from its outer end inward, like a snake.
   The centreline was measured off the artwork by hit-testing, not fitted: this spiral is
   hand-drawn, and a true Archimedean fit missed it by up to 32px, which a 62px stroke
   could not have hidden. 62 covers the band's widest point (55) and still clears the
   next turn in (77.5px of pitch), so the stroke can never reveal two turns at once.
   Entirely CSS: nothing here waits on JavaScript, so the overlay cannot strand a visitor
   on a white screen if a script fails to run. */
/* The overlay is fixed to the initial containing block, which EXCLUDES the scrollbar —
   so without this a scrollbar sits beside the white screen and the page can be scrolled
   behind it, landing the visitor mid-grid when it lifts. overflow animates as a discrete
   property: held hidden across the whole timeline and flipped back at the very last
   keyframe, so the duration lives in one place instead of being repeated in JS. If a
   browser declines to animate it, the failure is simply the scrollbar we started with. */
html:not(.no-loader) { animation:loader-lock 1.65s linear both; }
@keyframes loader-lock { 0%, 99.9% { overflow:hidden; } 100% { overflow:visible; } }

.loader {
  position:fixed; inset:0; z-index:100;
  display:flex; align-items:center; justify-content:center;
  background:#FFFFFF;
  animation:loader-dismiss .3s ease 1.35s both;
}
/* Set once by the inline gate in <head>. Repeat views inside a session skip it. */
.no-loader .loader { display:none; }
@keyframes loader-dismiss { to { opacity:0; visibility:hidden; } }

.loader__mark { width:min(46vmin, 340px); height:auto; }
.loader__mark path { fill:#000000; }

/* pathLength normalises the traced polyline to 1000 units, so the dash values here do
   not have to know its real length — and stay correct if the trace is ever re-sampled. */
.loader__trace {
  stroke-dasharray:1000; stroke-dashoffset:1000;
  animation:loader-draw .85s ease-in-out .2s forwards;
}
@keyframes loader-draw { to { stroke-dashoffset:0; } }

/* This artwork brackets the spiral with two dots rather than trailing three off it: one
   above the outer end, one sitting in the middle. So the lead dot places the pen before
   the line starts, and the core dot lands as it arrives — the reveal ends on the centre
   rather than simply stopping there. */
.loader__dot { opacity:0; animation:loader-dot .15s ease both; }
.loader__dot--lead { animation-delay:0s; }
.loader__dot--core { animation-delay:1.05s; }
@keyframes loader-dot { to { opacity:1; } }

@media (prefers-reduced-motion:reduce) {
  /* The blanket rule at the foot of this sheet collapses DURATION but not DELAY, and
     this overlay is dismissed by a delayed animation — without this it would hold a
     white screen for the full 4.9s and then vanish instantly. */
  .loader, html:not(.no-loader) { animation-delay:0s !important; }
  .loader__trace { animation:none; stroke-dashoffset:0; }
  .loader__dot { animation:none; opacity:1; }
}

/* ---- header ---- */
/* Full-bleed. The header no longer tracks main's 1280px measure, so the mark sits
   hard left and the nav hard right against a flat 32px inset at any window width. */
.hdr {
  /* A grid, not space-between. The genre bar sits in the MIDDLE of the header, and
     space-between only centres a middle item when the two beside it happen to be the
     same width — the wordmark is 310px and the nav about 230, so it would sit visibly
     off-axis. `1fr auto 1fr` centres the middle column on the page axis whatever the
     two ends measure. Columns are assigned explicitly rather than auto-placed because
     About and Contact render an EMPTY genre bar: with auto-placement the nav would
     slide into column 2 there and stop being right-aligned. */
  display:grid; grid-template-columns:1fr auto 1fr; align-items:baseline;
  padding:32px var(--hdr-edge) 12px;
  /* Sticky and deliberately UNPAINTED. A background here would solve legibility by
     hiding the photographs, which is the opposite of the intent - the header is meant
     to ride over the work, and contrast.js recolours it per element instead.
     z-index:5 clears the in-flow grid but stays under the lightbox at 10, so opening a
     photograph still covers the header rather than leaving it floating on the black. */
  position:sticky; top:0; z-index:5;
}
/* The wordmark in the handwriting, at the size it already was: Architects Daughter at
   18px measured a 12.85px cap against the 13.14 of the mono this replaced, and 172px of
   glyph against 173, so nothing else in the header had to move.
   font-weight drops to 400 because it MUST: this face ships one weight, and leaving the
   old 700 would have the browser synthesise a bold by smearing the strokes — the one
   thing that makes a hand-lettered face look counterfeit.
   Tracking eased from .24em to .12em. The wide setting was drawn for a monospace, where
   the letters are already isolated; at .24em a handwriting face stops reading as one
   written word and becomes sixteen separate letters. */
.hdr__mark {
  grid-column:1; justify-self:start;
  font-family:var(--hand);
  font-size:18px; font-weight:400; letter-spacing:.12em; text-transform:uppercase;
}
.hdr__nav { grid-column:3; justify-self:end;
  font-family:var(--sans); font-size:14px; font-weight:600; letter-spacing:.18em; text-transform:uppercase; }
.hdr__nav a { color:var(--faint); }
.hdr__nav a + a { margin-left:18px; }
/* With the underline gone, colour alone separates the current page. Both ends clear
   WCAG 2.1 AA for normal text on --paper: --faint is 5.47:1, --ink is 16.56:1. */
.hdr__nav a[aria-current="page"] { color:var(--ink); }

/* ---- header over a frame ---- */
/* contrast.js clones the header into .hdr--over, stacks the clone exactly on top of the
   real one and clips it to the mats currently level with it, so the light/dark boundary
   can fall inside a glyph. Recolouring whole elements instead would flip the entire
   wordmark as soon as its box touched a mat and leave the half still hanging over
   --paper as white-on-white; the wordmark is 310px wide and the grid's left column
   starts under it, so that straddle is the normal case, not an edge case.
   Untreated, --ink on the mat measures 1.00:1 - a link does not dim, it disappears.
   The twin's palette is the overlay menu's, not flat white: white for the current page
   (17.43:1 on the mat) and #8F8A83 for the rest (5.09:1, clears AA at 14px), which
   keeps the current-page distinction a single white would flatten away. */
.hdr--over {
  position:fixed; top:0; left:0; width:100%;
  /* Above the real header's 5, still under the lightbox's 10. */
  z-index:6;
  pointer-events:none;
  /* Clipped to nothing until contrast.js measures — the twin must never flash as a
     solid white header on first paint. */
  clip-path:path("M0 0Z");
}
.hdr--over .hdr__mark { color:var(--on-ink); }
.hdr--over .hdr__nav a { color:var(--on-overlay-muted); }
.hdr--over .hdr__nav a[aria-current="page"] { color:var(--on-ink); }
/* The genre bar is in the header now, so it is in the clone too — and without this it
   would paint --faint on a black mat at 1.2:1, which is not dimmer, it is gone.
   filter.js writes aria-pressed with querySelectorAll across the document, so the twin
   tracks the selected genre rather than freezing on whatever was current when it was
   cloned. */
.hdr--over .filters__btn { color:var(--on-overlay-muted); }
.hdr--over .filters__btn[aria-pressed="true"] { color:var(--on-ink); }
.hdr--over .hdr__bar { background:var(--on-ink); }
/* The open menu paints its own full-screen palette and the header is inert behind it,
   so a second clipped copy of the wordmark on top would only ghost it. */
.hdr--over.hdr--open { display:none; }

/* ---- menu toggle ---- */
/* Never shown on the wide layout, and never shown without JS either: the rules that
   collapse the nav are all scoped to .hdr--js, which nav.js adds. Scripts off means a
   plain row of links rather than a button that cannot open anything. */
.hdr__toggle { display:none; }
.hdr--js .hdr__toggle:focus-visible { outline:2px solid var(--ink); outline-offset:2px; }
.hdr__bar {
  display:block; width:26px; height:2px; background:var(--ink);
  transition:transform .22s ease;
}
/* gap 7px + 2px bars puts the two centres 9px apart, so each travels 4.5px to meet in
   the middle and the pair reads as a cross. */
.hdr__toggle[aria-expanded="true"] .hdr__bar:first-child { transform:translateY(4.5px) rotate(45deg); }
.hdr__toggle[aria-expanded="true"] .hdr__bar:last-child { transform:translateY(-4.5px) rotate(-45deg); }
@media (prefers-reduced-motion:reduce) {
  .hdr__bar { transition:none; }
}

/* ---- layout: thumbnail rail + content ---- */
/* The rail is fixed, so it reserves its lane with padding rather than a flex column —
   that is what keeps it from ever overlapping the photographs at any window width. */
/* The shift has to come off one side AND go onto the other. .layout__main is centred
   in whatever this leaves, so trimming the left padding alone would move the centring
   axis by half the amount and the grid with it; moving the same 20px across shifts the
   axis by the full amount and leaves the available width untouched, which is what makes
   the nudge behave identically whether the grid is centred in a wide window or filling
   the lane in a narrow one. */
.layout {
  /* ONE fixed column in this lane now. The genre filters moved into the header, so the
     thumbnail rail sits alone at the page edge and the padding reserves only it.
     Reserved rather than overlapped: the rail is position:fixed and takes no space of
     its own, so without this the photographs would slide underneath it. */
  padding-left:calc(var(--hdr-edge) + var(--thumb-w) + var(--gap) - var(--grid-shift));
  padding-right:var(--grid-shift);
}
/* The rail ships with `hidden` and rail.js reveals it, so until then the lane must go
   or the grid sits indented against nothing — and with no rail to balance against,
   there is nothing for the nudge to correct either.
   This used to key off .filters[hidden], which no longer works at all: the filters are
   in the header now and are not inside .layout for :has() to see. */
.layout:has(.rail[hidden]) { padding-left:0; padding-right:0; }
/* And below the width where the rail moves to the bottom of the screen. */
@media (max-width:1099px) {
  .layout { padding-left:0; padding-right:0; }
}
.layout__main { max-width:var(--measure); margin-inline:auto; }

/* ---- genre filters ---- */
/* In the header, centred between the wordmark and the site nav. They used to be a
   fixed column down the left edge; the thumbnail rail has that lane now. Nothing here
   positions itself — the header's grid places it, which is what lets the same markup
   sit centred on one row above 640px and on its own centred row below it. */
.filters {
  grid-column:2; justify-self:center;
  display:flex; align-items:baseline;
  /* 18px is the nav's own `a + a` spacing. The two rows read as one system only if the
     rhythm matches, so this tracks that value rather than setting its own. */
  gap:18px;
}
.filters[hidden] { display:none; }
.filters__btn {
  /* No horizontal padding. It was there to give a vertical rail's buttons a hit area
     wider than their text; in a row it just adds 24px to every gap and breaks the
     rhythm the gap above is trying to match. */
  appearance:none; background:none; border:0; padding:0 0 3px; cursor:pointer;
  font-family:var(--sans); font-size:12px; font-weight:600; letter-spacing:.18em;
  text-transform:uppercase; color:var(--faint); text-align:left;
}
.filters__btn[aria-pressed="true"] { color:var(--ink); }
.filters__btn:focus-visible { outline:2px solid var(--ink); outline-offset:3px; }
.filters__count { display:none; }

/* Below the grid's own breakpoint the rail would leave ~150px for photographs, so
   it returns to a row above the content. */
@media (max-width:639px) {
  .layout { padding-left:0; padding-right:0; }

  /* The wordmark and the burger share row 1; the three genres take a centred row of
     their own beneath them, and the nav — a full-screen overlay once open — takes row
     3. Every child is placed explicitly: an empty genre bar on About and Contact would
     otherwise let auto-placement shuffle the rest.
     Being INSIDE the sticky header is also what fixes the old scrolling bug for free.
     The bar used to be a sticky element of its own inside .layout, pinned by hand to
     var(--hdr-h) — a hardcoded guess at the header's height that had to be maintained
     alongside it. Now it simply travels with the header, and --hdr-h is gone. */
  .hdr {
    grid-template-columns:1fr auto;
    align-items:center;
    row-gap:16px;
  }
  .hdr__mark { grid-column:1; grid-row:1; }
  .filters { grid-column:1 / -1; grid-row:2; justify-self:center; }
  .hdr__nav { grid-column:1 / -1; grid-row:3; justify-self:stretch; }
  .hdr--js .hdr__toggle {
    grid-column:2; grid-row:1; justify-self:end;
    display:flex; flex-direction:column; justify-content:center; align-items:flex-end;
    gap:7px;
    /* 44x44 minimum pointer target (WCAG 2.5.8). The bars are 26px and right-aligned
       inside it, so the icon still reads as sitting on the 32px page edge while the
       tap area extends past it. */
    width:44px; height:44px; padding:0;
    appearance:none; background:none; border:0; cursor:pointer;
  }
  .hdr--js .hdr__nav { display:none; flex:1 0 100%; }
  /* Full-page overlay rather than an in-flow panel. z-index sits above the lightbox's
     10; the two can never be open together, since the lightbox makes header.hdr inert
     and the overlay covers the photographs that open it. */
  .hdr--js .hdr__nav[data-open] {
    position:fixed; inset:0; z-index:30;
    background:var(--overlay);
    display:flex; flex-direction:column; justify-content:center;
    /* The panel centres its links on both axes: align-items handles the column
       cross-axis, text-align catches a link that wraps onto a second line. */
    align-items:center; text-align:center;
    gap:24px;
    padding:0 var(--hdr-edge);
    font-size:48px; line-height:1.05;
    /* Display size carries the emphasis here, so the 600 and the all-caps the 14px
       row needs both come off — the labels render as authored: Work, About, Contact. */
    font-weight:400; text-transform:none;
    /* .18em is tuned for the 14px row and measures out to 8.6px a letter at 48px,
       which pushes CONTACT past a 320px viewport. Display sizes want less tracking,
       not the same amount scaled up. */
    letter-spacing:.06em;
    overflow-y:auto;
  }
  .hdr--js .hdr__nav[data-open] a { color:var(--on-overlay-muted); }
  .hdr--js .hdr__nav[data-open] a[aria-current="page"] { color:var(--on-overlay); }

  /* The header rides above the page so the close control is still reachable, and its
     ink inverts for the same contrast reason as the links. */
  /* z-index only - NOT `position:relative`. The header is sticky now, and dropping it
     back to relative here would snap it from the top of the viewport to its document
     position the moment the menu opened after a scroll, carrying the only control that
     closes the menu off the top of the screen. Sticky takes z-index and opens the same
     stacking context, so nothing else has to change. */
  .hdr--js.hdr--open { z-index:40; }
  /* The overlay is a CHILD of the header, so raising the header alone hides the very
     control that closes it: z-index:40 opens a stacking context, and inside it the
     nav's 30 outranks the wordmark and toggle sitting at auto. They have to be lifted
     past the overlay within that same context, not just alongside it. */
  .hdr--js.hdr--open .hdr__mark,
  .hdr--js.hdr--open .hdr__toggle { position:relative; z-index:31; }
  .hdr--js.hdr--open .hdr__mark { color:var(--on-overlay); }
  .hdr--js.hdr--open .hdr__bar { background:var(--on-overlay); }
  /* The row rule spaces siblings horizontally; in a column it would indent them. */
  .hdr--js .hdr__nav[data-open] a + a { margin-left:0; }
}

/* ---- focus view ---- */
/* One photograph, held still in the middle of the screen; the page's scroll drives the
   thumbnail rail past a fixed anchor instead of moving the photograph. rail.js adds
   .layout--focus, so without JavaScript the plain scrolling column below is what ships
   — the fallback is the whole gallery, not a broken version of it.
   The grid keeps every frame in the DOM: the active one is shown and the rest are
   display:none. That way the EXIF strip, the <picture> and the lightbox hook all come
   from the frame that already existed, and nothing here re-renders a photograph. */
.layout--focus .grid {
  position:fixed;
  /* Dead centre on both axes. It used to be nudged 30px down to clear the genre
     description that hung above it, and 30px was that description's problem, not the
     photograph's — the description is gone, so the nudge goes with it. */
  top:50%; transform:translateY(-50%);
  /* Full bleed, so the frame inside can centre on the PAGE axis rather than in the
     space left over beside the rail. The rail is a narrow strip at the edge now; a
     photograph centred in the remainder would read as pushed right. Clearance from the
     rail is enforced by the frame's width cap below instead of by this edge. */
  left:0; right:0;
  padding:0; margin:0; gap:0;
}
.layout--focus .frame { display:none; }
/* :not([hidden]) is load-bearing, not tidiness. `.frame[hidden]` is (0,2,0) and this
   rule is (0,3,0), so without it a frame left over from the previous genre — hidden by
   filter.js but still carrying .is-active — out-specifies its own hidden attribute and
   stays on the stage. */
.layout--focus .frame.is-active:not([hidden]) {
  display:block;
  /* Fit the viewport's HEIGHT as well as its width. 3:2 plus the mat's bands and the
     two label rows is about 220px of chrome, so the width the height can afford is
     (viewport - 220) x 1.5. Without this the photograph overflows a short window and
     there is no scrolling left to reach the rest of it. */
  /* Four caps, and the last is what keeps a centred photograph off the rail. Centring
     means the left edge sits at (100vw - width) / 2, so the width has to stay under
     100vw minus TWICE the lane the rail needs — the cap has to be symmetric even
     though the rail is only on one side, or centring undoes it.
     The lane is the page inset, the thumbnail, the 30px the active one swings right at
     the peak of the bulge, and a gap: 32 + 80 + 30 + 30 = 172 a side. */
  width:min(
    100%,
    var(--measure),
    calc((100vh - 300px) * 1.5),
    calc(100vw - 2 * (var(--hdr-edge) + var(--thumb-w) + var(--bulge-max) + var(--gap)))
  );
  margin-inline:auto;
}
/* The scroll length: rail.js sets --focus-length from the number of photographs, since
   the grid no longer contributes any height of its own. */
.layout--focus .layout__main { min-height:var(--focus-length, 100vh); }
/* ---- thumbnail rail ---- */
/* Fixed and vertically centred, like the filter rail beside it. The photo column runs
   several thousand pixels per genre while the rail is only a few hundred, so letting it
   scroll away in normal flow would leave the gutter empty for most of the page. Pinned,
   it stays an index the whole way down.
   Ships hidden and is revealed by rail.js — without JavaScript it could neither show
   which photograph you are on nor travel to one, and a strip of inert thumbnails that
   look clickable is worse than no strip. */
.rail {
  position:fixed;
  /* Hard against the page inset — the lane the genre filters used to occupy. */
  left:var(--hdr-edge);
  /* Anchored at the top and slid by --rail-y, which rail.js recomputes from the scroll
     position. No max-height and no overflow: the strip is DELIBERATELY taller than the
     screen and runs past both edges — that is what makes it feel like a filmstrip being
     wound rather than a list being paged. */
  top:0;
  transform:translateY(var(--rail-offset, 0px));
  width:var(--thumb-w);
  display:flex; flex-direction:column; gap:22px;
  z-index:4;   /* under the sticky header's 5, so it passes beneath the wordmark */
  pointer-events:none;   /* re-enabled per item, so the empty column is click-through */
}
.rail::-webkit-scrollbar { display:none; }
.rail[hidden] { display:none; }

.rail__item {
  appearance:none; border:0; padding:0; background:none; cursor:pointer;
  display:block; width:100%; line-height:0;
  pointer-events:auto;
  /* Every thumbnail at the same weight — the current one is not picked out here. The
     stage already says which photograph is showing, and .is-current is kept only so
     rail.js can set aria-current for anyone who cannot see the stage. */
  opacity:1;
  /* The snake. --bulge is set per item by rail.js from that thumbnail's distance to the
     anchor, so the offset peaks on whichever one is current and tapers away above and
     below it. The column bends around the active photograph rather than sliding as a
     block — which is the whole character of the movement.
     TRANSLATE ONLY. This used to also scale the current thumbnail up, and scaling grows
     an element into the gaps either side of it — so the spacing tightened around
     whichever photograph was active and opened back up as it passed. The gap below is a
     layout value; only a transform that leaves the box alone keeps it constant on
     screen. The current one is picked out by opacity and by how far it swings instead. */
  transform:translateX(calc(var(--bulge, 0) * var(--bulge-max)));
  transform-origin:left center;
}
.rail__item[hidden] { display:none; }
.rail__item img { display:block; width:100%; height:auto; aspect-ratio:3 / 2; object-fit:cover; }
/* No hover or current state on opacity: they are all one weight now. The pointer
   cursor and the focus ring are what remain as affordances, and the focus ring still
   has to be there — losing the hover cue must not also lose the keyboard one. */
.rail__item:focus-visible { outline:2px solid var(--ink); outline-offset:3px; }

/* ---- rail as a bottom strip ---- */
/* Below the width that fits three columns the rail turns on its side: a horizontal
   filmstrip pinned to the bottom of the screen, wound left and right by the same scroll
   that wound it up and down. Nothing in rail.js hard-codes this breakpoint — it reads
   flex-direction back off this rule to know which axis it is driving, so there is one
   definition of "narrow" and it lives here. */
@media (max-width:1099px) {
  .rail {
    top:auto; bottom:22px; left:0; right:0;
    width:auto;
    flex-direction:row;
    /* Same scalar as the desktop column, applied across instead of down. */
    transform:translateX(var(--rail-offset, 0px));
  }
  /* Fixed basis: in a row, flex items would otherwise be sized from their content and
     the pitch would stop being uniform. */
  .rail__item { flex:0 0 var(--thumb-w); width:var(--thumb-w); }
  /* The bulge lifts the current photograph TOWARDS the stage rather than sideways —
     the same gesture rotated with the strip. Negative because up is negative y. */
  .rail__item { transform:translateY(calc(var(--bulge, 0) * var(--bulge-max) * -1)); }

  /* The stage loses the thumbnail lane — the strip is along the bottom now — and sits
     above it, edge to edge. The band between 640 and 1099 takes its left edge back
     below; on a phone the filters are a row at the top of the page and nothing is
     beside the photograph to make room for. */
  .layout--focus .grid {
    left:var(--hdr-edge); right:var(--hdr-edge);
    top:calc(50% - 34px);
  }
  .layout--focus .frame.is-active:not([hidden]) {
    width:min(100%, calc((100vh - 330px) * 1.5));
  }

  /* A row of thumbnails is far wider than a phone and is translated past both edges.
     clip, not hidden: hidden on the root would make it a scroll container and break the
     sticky header, whereas clip suppresses the overflow without creating one. */
  html[data-focus] { overflow-x:clip; }
}

/* ---- grid ---- */
.grid { display:grid; grid-template-columns:1fr; gap:var(--row-gap) var(--gap); padding-bottom:40px; }

.frame { margin:0; }
.frame[hidden] { display:none; }
/* A fixed four-column grid, NOT space-between. With space-between the gaps are whatever
   is left over after the content, so a column's position is derived from the width of
   its own text — "f/2 · 1/125" is wider than "f/2 · 1/50", and the column jumped 4px as
   the stage changed photograph. Equal tracks pin every column in place whatever it says.
   It also aligns the two rows to each other: they were laid out independently, and the
   label row had no horizontal padding while the value row had 8px, so the headings never
   quite sat over their values (Camera at 693 against X100VI at 669). */
.frame__labels, .frame__meta {
  display:grid;
  grid-template-columns:repeat(4, 1fr);
  gap:10px;
  padding-inline:8px;
}
/* Frame number reads from the left edge, ISO from the right, the two wider fields
   centred in their tracks — the arrangement the flex version happened to produce. */
.frame__labels > :nth-child(2), .frame__meta > :nth-child(2),
.frame__labels > :nth-child(3), .frame__meta > :nth-child(3) { text-align:center; }
.frame__labels > :last-child, .frame__meta > :last-child { text-align:right; }
.frame__labels { font-size:10px; letter-spacing:.16em; text-transform:uppercase; color:var(--label); padding-bottom:4px; }
.frame__meta {
  font-size:10px; font-weight:500; letter-spacing:.1em; text-transform:uppercase;
  /* The mono that used to sit here gave every digit the same advance. Geist is
     proportional, and these numbers are rewritten on every filter change — without
     tabular figures the EXIF columns would shift as 01 becomes 08. */
  font-variant-numeric:tabular-nums;
  /* Same --mat as the frame below it, deliberately. The strip sits flush on the mat
     with no gap, so the two read as one black shape — leaving this at --ink while the
     mat went pure black would band them into two visibly different blacks. */
  background:var(--mat); color:var(--on-ink); padding-block:6px;   /* inline padding comes from the shared grid rule */
}
/* The photograph is matted on black like a mounted print — a deeper margin below
   than above, which is how a print is actually mounted so it does not look bottom-
   heavy on a wall. Applied to the <a> wrapper, so the frames grid.js builds for
   deferred photographs inherit it without touching that code path. */
.frame__link {
  display:block; cursor:zoom-in;
  background:var(--mat); padding:var(--mat-t) var(--mat-x) var(--mat-b);
}
.frame__link:focus-visible { outline:2px solid var(--ink); outline-offset:3px; }
/* Dark, not the old paper tone — a light placeholder would flash as a pale rectangle
   inside the black mat before the image decodes. */
/* The ratio is declared, not inherited from the file. lib/images.js cover-crops every
   variant to RATIO = 3/2, but heightFor() rounds — 800/1.5 encodes as 800x533, which is
   1.50094 — and height:auto propagates that error straight into the layout. Stating
   aspect-ratio pins the box to a true 3:2 whatever the encoder rounded to, and reserves
   the space before the image decodes; object-fit absorbs the sub-pixel difference by
   cropping rather than stretching. */
.frame img {
  display:block; width:100%; height:auto;
  aspect-ratio:3 / 2; object-fit:cover;
  background:var(--lb-rule);
}


/* ---- genre reveal ---- */
/* ONE wipe across the whole content block. filter.js puts the class on .layout__main,
   which wraps the description and the grid, so the sweep crosses the full width and the
   full height of what changed in a single pass and the photographs are uncovered by it
   — rather than each frame running its own animation, which read as a cascade of parts
   instead of one change of screen.
   inset() with a 100% right inset hides the block outright without touching layout or
   opacity, so the content is uncovered rather than faded in. `both` holds the from-state
   before the animation's first frame, which is what stops a flash of the new grid at
   full opacity on the way in.
   filter.js adds the class on the click and popstate paths only, never on the first
   render, so the page does not animate itself on load. The global reduced-motion rule at
   the foot of this file collapses the duration to nothing, which is the right behaviour
   here — animationend still fires, so the class still cleans itself up. */
@keyframes reveal-lr {
  from { clip-path:inset(0 100% 0 0); }
  to   { clip-path:inset(0 0 0 0); }
}
.is-revealing { animation:reveal-lr 1.1s ease-in-out both; }

.deferred-list { font-family:var(--sans); font-size:13px; line-height:1.9; padding:0 0 40px 1.1em; }
.deferred-list a { color:var(--muted); text-decoration:underline; }


/* The about page is the only one with a paper ground under it. Scoped with :has(.prose)
   rather than a class on <body>, because nothing renders a body class and .prose exists
   on exactly this page — the same trick the layout uses to detect a hidden filter rail.
   It has to sit on BODY, not html: body already paints var(--paper) across the whole
   viewport (min-height:100vh), so a background on html would be hidden behind it.
   --paper stays as the background COLOUR beneath the image, which means a missing or
   failed texture leaves the page exactly as it was rather than blank. */
body:has(.prose) {
  background-image:url("/textures/paper.jpg");
  background-size:cover;
  background-position:center;
  /* Fixed to the viewport: the ground should not slide under the text if the page ever
     grows past one screen, and it avoids any seam from a repeat. */
  background-attachment:fixed;
  background-repeat:no-repeat;
}

/* ---- prose (about) ---- */
/* Centred on both axes, the same way the contact page is: main becomes a flex column
   only on the page that needs it, and .prose grows into it. Growth rather than
   height:100% — a stretched flex item is not a definite height, so a percentage would
   resolve against nothing and drop the text back to the top. */
body > main:has(.prose) { display:flex; flex-direction:column; }
.prose {
  flex:1;
  display:flex; flex-direction:column; justify-content:center;
  align-items:center; text-align:center;
  font-family:var(--hand); font-size:24px; line-height:1.7;
  /* Pure black, not --prose. Deliberately literal rather than a token: --prose (#3A3833)
     is the site's reading colour and is still what the genre descriptions use, so this
     is a one-off for this page and naming it would imply a system that does not exist.
     19.96:1 on --paper, against --prose's 11.13:1. */
  color:#000000;
  /* ch, not em: the measure is a character count, and ch is the width of the applied
     font's own "0", so it tracks that count when the face changes. Narrowed from 58 to
     46 because centred ragged-both-sides text is harder to track back along than
     left-aligned text, and wants shorter lines. */
  max-width:46ch;
  margin-inline:auto;
  /* 40 + 120 on the bottom. justify-content:center centres within the CONTENT box, so
     bottom padding moves that box's midpoint up by half of whatever is added — 120 here
     lifts the block 60px. Done this way rather than with a transform so it still centres
     itself on whatever height is left, instead of being pinned to a hard offset that a
     short window would push under the header. */
  padding-block:32px 160px;
}
/* The mark above the text. flex:none so the column's centring cannot stretch it, and
   the 24px is a margin rather than a gap on .prose — a gap would apply between every
   paragraph too, and the paragraphs carry their own 20px. */
.prose__mark {
  flex:none;
  width:48px; height:48px;
  margin:0 0 24px;
}
.prose p { margin:0 0 20px; }
.prose p:last-child { margin-bottom:0; }

@media (prefers-reduced-motion:reduce) {
  *, *::before, *::after { animation-duration:.01ms !important; transition-duration:.01ms !important; }
}

/* ---- lightbox ---- */
body.is-locked { overflow:hidden; }
.lightbox {
  position:fixed; inset:0; z-index:10; background:var(--lb-ground);
  display:flex; align-items:center; justify-content:center; padding:clamp(16px,5vw,56px);
}
.lightbox[hidden] { display:none; }
.lightbox__img { max-width:100%; max-height:100%; object-fit:contain; }
.lightbox__close, .lightbox__nav {
  position:absolute; appearance:none; background:none; border:0; cursor:pointer;
  font-family:var(--sans); color:var(--grey); letter-spacing:.16em; text-transform:uppercase;
}
.lightbox__close { top:20px; right:24px; font-size:9px; }
.lightbox__nav { top:50%; transform:translateY(-50%); font-size:22px; padding:16px; }
.lightbox__nav--prev { left:8px; }
.lightbox__nav--next { right:8px; }
.lightbox__close:hover, .lightbox__nav:hover { color:var(--paper); }
.lightbox__close:focus-visible, .lightbox__nav:focus-visible { outline:2px solid var(--paper); outline-offset:2px; }

/* ---- contact ---- */
/* Two pairs, centred on both axes, in the exact space the header leaves — no constant
   standing in for how tall that chrome happens to be.
   The centring is done with flex GROWTH, not height:100%. body stretches <main> to fill
   the leftover viewport, but a stretched flex item is not a definite height, so a
   percentage on the child resolves against nothing and collapses to its content —
   which parked the pairs at the top of the page. Making main a flex column and letting
   .contact grow into it sidesteps percentage resolution entirely.
   Scoped with :has() so only the contact page's main becomes a flex column; the same
   trick the layout uses to drop the rail lane when the filters are hidden. */
body > main:has(.contact) { display:flex; flex-direction:column; }
.contact {
  flex:1;
  display:flex; flex-direction:column; align-items:center; justify-content:center;
  text-align:center;
  /* justify-content:center centres within the CONTENT box, so bottom padding moves that
     midpoint up by half of it — 120 lifts the pairs 60px. A transform would pin them to
     a hard offset; this keeps them centring on whatever height is left. */
  padding-bottom:120px;
}
/* Geist at 14px — the same face and size as the rest of the interface, so these read
   as labels on the values rather than as copy in their own right. */
.contact__label {
  font-family:var(--sans); font-size:18px; line-height:1.4;
  color:var(--muted);
  margin:0 0 12px;
}
/* The handwriting, at the size the values were asked for. Underlined explicitly: the
   reset at the top of this sheet strips underlines from every <a> on the site. */
.contact__value {
  font-family:var(--hand);
  /* 48px wherever it fits, and no wider than the screen. The address is 593px at 48px,
     so on a phone it ran off BOTH edges — the beginning and the end were clipped and it
     could not be read at all.
     The cap still matters at 28px: the address is 346px there, against a container of
     only 326px on a 390px phone — it would overflow by 20px. The line has to fit the
     CONTAINER, which is the viewport less main's 64px of padding, so 6vw scales it
     below ~467px wide and holds 28px above that. The wrap rule underneath is the
     backstop for anything narrower still — a broken address is recoverable, a clipped
     one is not. */
  font-size:min(28px, 6vw);
  line-height:1.3;
  max-width:100%; overflow-wrap:anywhere;
  color:var(--ink);
  text-decoration:underline;
  /* The default sits the rule right on the descenders of this face. Scaled with the
     type. */
  text-underline-offset:5px;
}
.contact__value:focus-visible { outline:2px solid var(--ink); outline-offset:3px; }
/* Separates the two pairs. Deliberately much larger than the 12px inside a pair, so
   each label reads as belonging to the value under it rather than the one above. */
.contact__value + .contact__label { margin-top:40px; }
