/* =========================================================================
   Jamie Wolfeld — app.css

   The whole system is the token block below. Nothing in this file uses a
   spacing or type value that isn't one of these. If a component needs a value
   that isn't here, that's a design decision, not a one-off rule.

   Token names follow the John Brown Projects theme (--edge-pad, --paper,
   --ink) so this reads as the same codebase.

   Reduction against the live Wix site, measured (see docs/REVIEW.md):
     typefaces  6 -> 1
     font sizes 12 -> 4
     line heights 8 (127 elements unset) -> 3, all explicit
     left edges 6 -> 1
   ========================================================================= */

:root {
  /* --- spacing --- */
  --edge-pad:    clamp(20px, 5vw, 64px);
  --section-pad: clamp(48px, 8vw, 112px);

  /* Content starts higher than it ends. A page opens under the header with
     about half the space it closes with — the top gap is dead air, the bottom
     one is breathing room. One token, so every template starts at the same
     height. */
  --top-pad:     clamp(24px, 3vw, 44px);

  /* Two gaps, not one: cards carry a title and a meta line underneath, so rows
     need more separation than columns or the caption of one row crowds the
     image of the next. Row gap is derived, not picked. */
  --gap:         24px;
  --gap-row:     calc(var(--gap) * 2);

  --space-xs:    4px;
  --space-s:     12px;
  --space-m:     32px;
  --space-l:     48px;

  /* --- type: four sizes --- */
  --fs-display: clamp(2rem, 1.4rem + 3vw, 3.5rem);
  --fs-heading: clamp(1.25rem, 1.1rem + 0.75vw, 1.625rem);
  /* Archive row titles. A fifth size, added deliberately: the row layout gives
     each project a lot of space and --fs-heading was too quiet in it. */
  --fs-display-s: clamp(1.375rem, 1.15rem + 1vw, 1.75rem);
  --fs-body:    clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
  --fs-meta:    0.875rem;
  /* The nav sits one small step above meta so it reads as navigation rather
     than fine print. Matches the Old Oak nav, which already lands at 15px. */
  --fs-nav:     0.9375rem;

  /* --- three line heights, unitless so they scale with clamp() --- */
  --lh-tight: 1.1;
  --lh-snug:  1.25;
  --lh-loose: 1.6;

  --font-sans: "Manrope", ui-sans-serif, -apple-system, "Segoe UI", Roboto,
               "Helvetica Neue", Arial, sans-serif;

  /* --- colour ---

     Paper is a cream a hair off white, not #fff. On a page that is mostly
     photography, pure white is the one value on screen brighter than anything
     in the images, so it competes with them; dropping it a couple of percent
     and warming it lets the stills be the brightest thing. It is deliberately
     below the threshold where anyone would call it "a cream page".

     The hairline follows it warm. A neutral grey rule on a warm ground reads
     cold, which is the giveaway that the ground was tinted and nothing else
     was — same lightness as before, same hue as the paper. */
  --paper:     #fcfbf8;
  --ink:       #111111;
  --ink-muted: #5c5c5c;
  --line:      #e4e2db;

  --ratio-card: 3 / 2;

  /* Three durations, all derived from one. Component motion is --dur; a page
     cross-fade is shorter because it is on the critical path of every click,
     and a theme change is longer because the whole screen is moving at once
     and a fast full-page colour swap reads as a flash. */
  --dur: 300ms;
  --dur-page:  calc(var(--dur) * 0.85);
  --dur-theme: calc(var(--dur) * 1.3);
  /* A push-in is a camera move and reads wrong at UI speed. */
  --dur-push:  calc(var(--dur) * 2.2);
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --paper:     #0d0d0d;
    --ink:       #f2f2f2;
    --ink-muted: #a3a3a3;
    --line:      #2b2b2b;
  }
}

:root[data-theme="dark"] {
  --paper:     #0d0d0d;
  --ink:       #f2f2f2;
  --ink-muted: #a3a3a3;
  --line:      #2b2b2b;
}

@media (prefers-reduced-motion: reduce) {
  :root { --dur: 1ms; }
}

/* --- page transition ---------------------------------------------------

   Cross-document View Transitions. The browser owns the fade, so the click
   starts the navigation IMMEDIATELY — nothing is held back waiting for an
   animation to finish, which is exactly what the previous JS fade did: it
   faded out first and only then began fetching, so every navigation cost the
   fade plus the full round trip.

   Identical settings to the Old Oak site — the two should feel the same. */

@view-transition {
  navigation: auto;
}

::view-transition-old(root),
::view-transition-new(root) {
  /* Symmetrical cross-fade — smooth rather than snappy, and identical in
     both directions so back feels the same as forward.

     ease-in-out, not the standard-easing curve: the outgoing page needs to
     ease OUT as well as in, and cubic-bezier(0.4, 0, 0.2, 1) starts fast, so
     the old page fell away the instant you clicked. Symmetrical easing on both
     halves is what makes it read as one page dissolving into the next rather
     than one snapping off and another arriving. */
  animation-duration: var(--dur-page);
  animation-timing-function: ease-in-out;
  animation-fill-mode: both;
}

/* The light/dark swap rides the same machinery, a touch slower — a change of
   scheme is a bigger visual jump than a change of page, and wants longer to
   land. Same settings as the Old Oak site. */
html.is-theme-switching::view-transition-old(root),
html.is-theme-switching::view-transition-new(root) {
  animation-duration: var(--dur-theme);
  animation-timing-function: ease-in-out;
}

html.is-theme-switching::view-transition-old(root) { mix-blend-mode: normal; }

/* The half flip.
   
   The icon rotating 180° was a plain CSS transition, and the View Transition
   swallowed it: the browser captures the page before and after the swap and
   cross-fades two still images, so the transform never gets a frame to run in.
   Measured — one transform value from start to finish.

   Naming the icon pulls it out of the root snapshot into its own group, where
   it can be animated properly. The outgoing half turns away and the incoming
   half turns in from the other side: ninety degrees each, one hundred and
   eighty across the pair, so it reads as one object flipping rather than two
   icons swapping.

   Named only while switching theme, so a page navigation does not flip it. */
html.is-theme-switching .theme-toggle__icon { view-transition-name: jw-theme-icon; }

html.is-theme-switching::view-transition-old(jw-theme-icon) {
  animation: jw-flip-out var(--dur-theme) ease-in both;
}

html.is-theme-switching::view-transition-new(jw-theme-icon) {
  animation: jw-flip-in var(--dur-theme) ease-out both;
}

@keyframes jw-flip-out {
  to { transform: rotate(90deg); opacity: 0; }
}

@keyframes jw-flip-in {
  from { transform: rotate(-90deg); opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  html.is-theme-switching::view-transition-old(jw-theme-icon),
  html.is-theme-switching::view-transition-new(jw-theme-icon) { animation: none; }
}

/* Header and footer are constant across pages, so holding them still stops
   the furniture flickering while the content changes underneath. */
.header,
.footer {
  view-transition-name: none;
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

/* --- fonts ------------------------------------------------------------- */

/* Self-hosted variable font, 40KB total for both subsets. One file covers
   400–700, so the two weights cost nothing extra over one.

   latin-ext is not optional here — the site has real content at
   /post/tiña-people and /post/tiña-rosalina. */

@font-face {
  font-family: "Manrope";
  font-weight: 400 700;
  font-style: normal;
  font-display: swap;
  src: url("../fonts/manrope-latin.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
                 U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: "Manrope";
  font-weight: 400 700;
  font-style: normal;
  font-display: swap;
  src: url("../fonts/manrope-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF,
                 U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF,
                 U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* --- base -------------------------------------------------------------- */

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

html {
  overflow-x: hidden;
  background: var(--paper);
  color: var(--ink);
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font-sans);
  font-size: var(--fs-body);
  font-weight: 400;
  line-height: var(--lh-loose);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

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

a { color: inherit; text-decoration-thickness: 1px; text-underline-offset: 0.2em; }

/* Keep focus rings for keyboard users; suppress only on programmatic focus. */
:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}
main:focus, [tabindex="-1"]:focus { outline: none; }

.u-sr-only {
  position: absolute !important;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.skip-link {
  position: absolute;
  left: var(--edge-pad);
  top: -100%;
  z-index: 100;
  padding: var(--space-s);
  background: var(--paper);
  color: var(--ink);
}
.skip-link:focus { top: var(--space-s); }

/* --- type -------------------------------------------------------------- */

h1, h2, h3 { font-weight: 700; margin: 0; }

/* Headings are differentiated by weight and SPACE before size — so the space
   has to actually be there. Set on the heading, not on the next element: a
   `h1 + *` rule loses to `.grid { margin: 0 }` on specificity, which is exactly
   how the page h1 ended up sitting on top of the grid. */
/* The heading/content relationship, defined once and applied everywhere.
   A page title gets --space-l beneath it, a section heading gets --space-m.
   Nothing else sets its own. Components that need a tighter pairing (the card
   title sitting on its meta line) override with a smaller token, never a new
   value. */
h1, .display        { margin-bottom: var(--space-l); }
h2, h3, .heading    { margin-bottom: var(--space-m); }

h1, .display {
  font-size: var(--fs-display);
  line-height: var(--lh-tight);
  letter-spacing: -0.015em;
}

h2, h3, .heading {
  font-size: var(--fs-heading);
  line-height: var(--lh-snug);
}

p { margin: 0 0 var(--space-s); }
p:last-child { margin-bottom: 0; }

.meta {
  font-size: var(--fs-meta);
  line-height: var(--lh-snug);
  color: var(--ink-muted);
}

/* --- layout ------------------------------------------------------------ */

/* One edge. Header, main, footer and any absolutely-positioned overlay all
   take their inset from --edge-pad. This is the single decision that does most
   of the work — see docs/DESIGN.md. */

.header,
.site-main,
.footer {
  padding-inline: var(--edge-pad);
}

.site-main {
  display: block;
  padding-block: var(--top-pad) var(--section-pad);
}

.section + .section { margin-top: var(--section-pad); }

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

.header {
  /* The nav overlay is a CHILD of the header, so raising the header alone does
     nothing — it just creates a stacking context in which the overlay
     (z-index 90) still paints over its sibling button. The name and the toggle
     are lifted above it individually below. Without that, the open panel
     covers the X: rendered, invisible, unclickable. */
  position: relative;
  z-index: 100;
  background: var(--paper);

  padding-block: var(--space-m);
  display: flex;
  align-items: baseline;
  /* Not space-between: with the theme toggle added there are three visible
     children, and space-between would strand the nav in the middle. The name
     sits at the start and an auto margin does the pushing instead — on which
     child depends on the breakpoint, so it is set with the nav rules below. */
  justify-content: flex-start;
  gap: var(--gap);
  border-bottom: 1px solid var(--line);
}

.header__name,
.mmenu,
.theme-toggle {
  /* Above the nav overlay (90), inside the header's stacking context. */
  position: relative;
  z-index: 95;
}

/* --- theme toggle ------------------------------------------------------

   One mark for both states: a circle with its left half filled, rotated 180°
   for the other theme so the fill swaps sides. It reads as the same object
   turning over, which is what makes a single control legible as a toggle.

   Hidden until app.js removes [hidden] — without the script it cannot change
   anything, and an inert control in the tab order is worse than no control.
   ----------------------------------------------------------------------- */

/* Centred on the nav text, not on the header's flex line.

   align-self: center would centre it in the line box the much larger site name
   defines, which left it about 3px above the menu words — and because
   --fs-heading is fluid, correcting that with a fixed offset would drift as the
   viewport changed. Instead the button carries a zero-width strut in the nav's
   own font metrics, so it has a real text baseline; baseline-aligning that to
   the nav puts the icon's centre on the menu words' centre at any size, and
   leaves the nav itself exactly where it was. */
.theme-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  align-self: baseline;
  font-size: var(--fs-nav);
  line-height: var(--lh-snug);
  width: 24px;
  height: 24px;
  padding: 0;
  border: 0;
  background: transparent;
  color: var(--ink-muted);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: color var(--dur) ease;
}

/* Zero-width space: the strut that gives the button a text baseline to align
   by. Width 0 so it costs nothing horizontally. */
.theme-toggle::before {
  content: "\200B";
  display: inline-block;
  width: 0;
}

.theme-toggle[hidden] { display: none; }

.theme-toggle__icon {
  width: 20px;
  height: 20px;
  transition: transform var(--dur) ease;
}

/* Dark: the fill swaps to the other half. The rotation is the transition. */
:root[data-theme="dark"] .theme-toggle__icon { transform: rotate(180deg); }

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .theme-toggle__icon { transform: rotate(180deg); }
}

@media (hover: hover) {
  .theme-toggle:hover { color: var(--ink); }
}

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

.header__name {
  font-size: var(--fs-heading);
  font-weight: 700;
  line-height: var(--lh-snug);
  letter-spacing: 0.02em;
  text-decoration: none;
}

.nav__list {
  display: flex;
  gap: var(--gap);
  list-style: none;
  margin: 0;
  padding: 0;
  font-size: var(--fs-nav);
  line-height: var(--lh-snug);
}

/* Desktop: the nav takes the slack, so it and the toggle sit together at the
   right edge. Mobile: the nav is fixed and out of flow, so the toggle does the
   pushing instead, and the hamburger is ordered last to keep it on the edge. */
@media (min-width: 768px) {
  .nav { margin-left: auto; }
}

@media (max-width: 767px) {
  .theme-toggle { margin-left: auto; }
  .mmenu { order: 2; }

  /* Centre, not baseline. Baseline alignment is right on desktop, where the
     site name and the nav links share a real text baseline and the toggle is
     tuned to sit on the nav's own centre. On mobile the nav is fixed and out
     of flow, so the toggle is baselining against a strut nothing can see, the
     hamburger is a block box whose baseline is its bottom edge, and the three
     land on three different heights — measured at 44.5, 47.4 and 42.5.

     With no shared baseline left to honour, a shared centre is the only thing
     that reads as aligned. */
  .header { align-items: center; }
  .theme-toggle { align-self: center; }
}

.nav a { text-decoration: none; }
.nav a[aria-current="page"] { text-decoration: underline; }

@media (hover: hover) {
  .nav a:hover { text-decoration: underline; }
}

/* --- dropdown ----------------------------------------------------------

   Opens on hover and on focus-within, so a keyboard user gets it by tabbing
   without needing a separate toggle. On mobile the nav is a full-screen
   overlay, so children are simply shown inline there instead.
   ----------------------------------------------------------------------- */

.nav__list li { position: relative; }

.nav__list .sub-menu {
  list-style: none;
  margin: 0;
  padding: 0;
}

@media (min-width: 768px) {
  .nav__list .sub-menu {
    position: absolute;
    top: 100%;
    /* Right-aligned, not left: the nav sits at the right edge of the page, so
       a left-aligned dropdown runs past the content edge into the gutter. */
    right: 0;
    left: auto;
    z-index: 20;
    min-width: 12rem;
    padding: var(--space-s) 0;
    background: var(--paper);
    border: 1px solid var(--line);
    display: grid;
    gap: var(--space-xs);

    opacity: 0;
    visibility: hidden;
    transition: opacity var(--dur) ease, visibility 0s linear var(--dur);
  }

  .nav__list > li:hover > .sub-menu,
  .nav__list > li:focus-within > .sub-menu {
    opacity: 1;
    visibility: visible;
    transition: opacity var(--dur) ease, visibility 0s;
  }

  .nav__list .sub-menu a {
    display: block;
    padding: var(--space-xs) var(--space-s);
    white-space: nowrap;
  }
}

@media (max-width: 767px) {
  /* Overlay: children sit under their parent, indented. */
  .nav__list .sub-menu {
    display: grid;
    gap: var(--space-s);
    margin-top: var(--space-s);
    padding-left: var(--space-m);
    font-size: var(--fs-body);
  }
}

/* --- hamburger: two lines that rotate to meet ------------------------- */

.mmenu {
  display: none;
  width: 24px;
  height: 20px;
  padding: 0;
  border: 0;
  background: transparent;
  color: currentColor;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}

.mmenu__line {
  position: absolute;
  left: 0;
  right: 0;
  height: 2px;
  background: currentColor;
  transition: transform var(--dur) ease, top var(--dur) ease, opacity calc(var(--dur) / 2) ease;
}

.mmenu__line--top    { top: 4px; }
.mmenu__line--middle { top: 9px; }
.mmenu__line--bottom { top: 14px; }

/* Middle fades first, outer lines rotate to meet it. Transform-only. */
.mmenu[aria-expanded="true"] .mmenu__line--middle { opacity: 0; transition-delay: 0ms; }
.mmenu[aria-expanded="true"] .mmenu__line--top    { top: 9px; transform: rotate(45deg);  transition-delay: 80ms; }
.mmenu[aria-expanded="true"] .mmenu__line--bottom { top: 9px; transform: rotate(-45deg); transition-delay: 80ms; }

@media (prefers-reduced-motion: reduce) {
  .mmenu__line { transition: none; transition-delay: 0ms !important; }
}

@media (max-width: 767px) {
  .mmenu { display: block; }

  .nav {
    position: fixed;
    inset: 0;
    z-index: 90;
    background: var(--paper);
    padding: var(--edge-pad);
    display: flex;
    align-items: center;
    opacity: 0;
    visibility: hidden;
    transition: opacity var(--dur) ease, visibility 0s linear var(--dur);
  }

  /* Keep the list clear of the header strip the toggle lives in. */
  .nav__list { padding-top: var(--space-l); }
  .nav.is-open {
    opacity: 1;
    visibility: visible;
    transition: opacity var(--dur) ease, visibility 0s;
  }
  .nav__list {
    flex-direction: column;
    gap: var(--space-m);
    font-size: var(--fs-heading);
  }
}

/* --- project grid: justified rows ---------------------------------------

   WHY THIS CHANGED. The grid used to place cards by column span, with the span
   picked from the image's ratio via jw_card_span() — four buckets, 4/5/6/8/10
   columns of twelve. Each card's width was therefore decided in isolation, and
   nothing ever looked at the row it landed in. When two spans happened to sum
   to twelve the row filled; when they summed to ten it did not, and you got a
   hole. I, Scream at span 4 beside Rising Up at span 6 left 196px of the 1152px
   row empty — 17% of it — for no reason a reader could see.

   That was luck dressed as a system.

   The fix is what the ratio idea actually implies. In a justified row every
   card is the SAME HEIGHT and its width is PROPORTIONAL TO ITS RATIO, so the
   row fills exactly, nothing is cropped, and a wide anamorphic frame still
   earns more width than a portrait — which was always the point.

   Mechanically: flex-grow is the ratio, so leftover space is shared out in
   proportion; flex-basis is also derived from the ratio, so wrapping happens at
   a sensible number of cards per row; and .card__media takes the same ratio, so
   heights match across a row without a crop.
   ------------------------------------------------------------------------ */

.grid {
  display: flex;
  flex-wrap: wrap;
  column-gap: var(--gap);
  row-gap: var(--gap-row);
  list-style: none;
  margin: 0;
  padding: 0;
}

.card {
  /* --ar is written per card by jw_project_card() from the image's real
     dimensions. The fallback keeps a card sane if it has no image at all. */
  --ar: 1.5;
  min-width: 0;
}

@media (min-width: 640px) {
  /* grow : shrink : basis — all three from the ratio. The basis multiplier is
     what decides how many cards land per row: bigger, and rows hold fewer
     larger cards. At 20rem these ratios settle at two per row on a desktop
     width, which is the density the archive was designed around.

     max-width is the guard for the last row. A justified row shares out
     leftover space, so a single card left over at the end would otherwise
     stretch to the full width and tower over everything above it — measured at
     1152x864 before this cap. Two-thirds still lets a lone card read as a
     deliberate wide one without becoming a billboard. */
  .card {
    flex: var(--ar) 1 calc(var(--ar) * 20rem);
    max-width: calc(66% - (var(--gap) / 2));
  }

  /* Same ratio on the frame, so every card in a row is exactly as tall as its
     neighbours and the image still fills it uncropped. */
  .card__media { aspect-ratio: var(--ar); }

  /* No feature-card override any more. It existed to break a grid where four
     of six cards were the same 16:9 span and everything queued into equal
     pairs. Widths now come from the real ratios, so that variety is already
     there — 499 against 629 in one row, 677 against 451 in the next — and
     overriding the growth only broke the equal heights that make a justified
     row read as one. The material decides. */
}

.card__link { text-decoration: none; display: block; }

.card__media {
  /* No aspect-ratio: the image keeps its own. This is the whole point — the
     ratio carries meaning about the medium. */
  overflow: hidden;
  background: var(--line);
  margin-bottom: var(--space-s);
}

.card__img {
  width: 100%;
  height: auto;
  display: block;
  transform-origin: center;
  transition: transform var(--dur-push) cubic-bezier(0.22, 0.61, 0.36, 1);
}

/* Only the empty placeholder needs a ratio, or it collapses to nothing. */
.card__img--empty { aspect-ratio: 3 / 2; }

.card__title {
  font-size: var(--fs-heading);
  line-height: var(--lh-snug);
  margin: 0 0 var(--space-xs);
}

.card__meta {
  font-size: var(--fs-meta);
  line-height: var(--lh-snug);
  color: var(--ink-muted);
  margin: 0;
}

.card[hidden] { display: none; }

/* Hover is a slow push-in, not a dim.
   
   These used to drop to 82% opacity, which blends the frame toward the page —
   so pointing at a piece of work made it recede. Backwards on any portfolio and
   especially on this one.
   
   A push-in is a camera move. It reads as the frame coming forward, it is the
   grammar of the medium the site is selling, and it makes the image the most
   present thing on the page for as long as you are looking at it. Slow, because
   a dolly is slow: --dur-push is roughly twice the site's component duration. */
@media (hover: hover) {
  .card:hover .card__img { transform: scale(1.04); }
}

/* The title does not underline on hover. The push-in above is the whole
   affordance — the card is one link, so the picture moving says the same
   thing the underline did, and saying it twice made a quiet grid of work
   look like a list of links. The keyboard still gets its ring, from
   .card__link:focus-visible. */

/* --- archive rows: four layout systems ----------------------------------

   One project per row, image and text, images keeping their native ratio —
   the arrangement jamiewolfeld.com/cinematography uses.

   Four spacing systems share this markup; the wrapper class picks one. They
   are reviewable side by side at /layout-styles/.

     .rows--even     one token governs the space between projects; the gap
                     inside a project is a quarter of it
     .rows--even2    same card, three horizontal starts on a six-step cycle
     .rows--image    the image is the unit and spacing derives from it
     .rows--measure  the reading measure is the constant; the image flexes
   ------------------------------------------------------------------------ */

.rows { display: grid; }
.row { display: grid; gap: var(--space-m); }

.row__media { display: block; }
.row__frame { display: block; background: var(--line); }
.row__frame iframe { display: block; width: 100%; height: 100%; border: 0; }
.row__link { display: block; }

.row__img {
  width: 100%;
  height: auto;
  display: block;
  transform-origin: center;
  transition: transform var(--dur-push) cubic-bezier(0.22, 0.61, 0.36, 1);
}

.row__img--empty { aspect-ratio: 3 / 2; background: var(--line); }

/* --- how tall a thumbnail may be ----------------------------------------

   Width is the column and has always been even: every landscape row is the
   same 636px. Height was not — it ran from 242px for Rising Up's 2.28 frame
   to 720px for a 9:16, so scrolling the archive was a series of jumps and the
   squarer work took three times the room of the wider work for no reason
   anyone chose.

   So width stays the maximum it was — the widest frame, Rising Up's, still
   fills the column — and a ceiling on height brings the rest down to meet it.
   Anything already under the ceiling is untouched: Rising Up and the 16:9
   work do not move.

   Done as a width computed from the permitted height, because a max-height
   against a definite width does not hold a ratio: the box keeps the width and
   crops instead. The shape is never in doubt this way, and nothing is cropped.

   The ceiling tracks the window so the archive stays scannable on a laptop
   without shrinking the work on a large screen.
   ------------------------------------------------------------------------ */

.rows .row {
  --row-max-h: clamp(260px, 46vh, 460px);

  /* An upright frame gets a quarter more height than the rest.

     Held to the same ceiling it came out 229px wide against 636px rows — a
     stamp beside full-width work, and Subway Takes is one of the pieces people
     react to most. A quarter taller is still nothing like the 720px it used to
     run to, and it reads as a card rather than a thumbnail. */
  --row-max-h-own: var(--row-max-h);
}

.rows .row--portrait { --row-max-h-own: calc(var(--row-max-h) * 1.25); }

.rows .row__frame {
  width: min(100%, calc(var(--row-max-h-own) * var(--row-ar, 1.5)));
}

/* A portrait poster needs no rule of its own any more.

   It used to be held to 80vh here, because a 9:16 filling its column ran to
   985px against an archive that otherwise topped out at 417 — a special case
   for the one shape that broke the rhythm.

   The height ceiling above now covers every shape, portrait included, so this
   became a second opinion on the same question and the two disagreed: the
   ceiling narrowed the frame while this still forced 720px of height, leaving
   the box at 229x720 with the picture letterboxed inside it. One rule for all
   of them, and the shape survives because the width is derived from it.
   ------------------------------------------------------------------------ */

.row__title {
  font-size: var(--fs-display-s);
  line-height: var(--lh-tight);
  margin: 0 0 var(--space-s);
}

.row__title a { text-decoration: none; }

.row__credits {
  display: flex;
  flex-direction: column;
  gap: 0;
  margin: 0 0 var(--space-s);
  font-size: var(--fs-meta);
  line-height: var(--lh-snug);
  color: var(--ink-muted);
}

/* Jamie's own role is the one line in the credits a visitor is scanning for, so
   it carries the weight and gets a little air on both sides. The named crew
   below it stay a tight block — they read as one list, not four equal lines.

   A margin rather than a bigger gap, because flex gap and margin add: the role
   keeps the same extra air on both sides whatever row-gap the layout sets. */
.row__role {
  font-weight: 700;
  margin-block: var(--space-xs);
}

/* The description stops at the bottom of the picture beside it.

   Row heights vary by shape now — 272px for an upright frame, 510px for a
   widescreen one — and the descriptions vary independently, so the text ran
   past the picture on some rows and stopped well short on others. Neither
   reads as a pair of columns.

   The cut is in whole lines, so the last line sits on the picture's baseline
   rather than being sliced through. How many lines that is depends on the
   row's own height and on how much of it the title and credits have already
   taken, which only the browser knows: app.js measures each row and sets
   --row-desc-lines. The 6 here is what a row gets without JavaScript — close
   to the median of what the measurements come out at, and the server still
   trims the copy, so a row degrades to roughly the right length rather than
   to the whole article.
   ------------------------------------------------------------------------ */

.row__desc {
  margin: 0;
  max-width: 46ch;
  font-size: var(--fs-body);
  line-height: var(--lh-loose);

  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: var(--row-desc-lines, 6);
  overflow: hidden;
}

/* Stacked, the picture is above the text, so there is no bottom edge to line
   up with and the measurement is switched off.

   A fixed cut stays, though. The server used to hold these to 48 words and
   now sends 150 for the clamp to work with, which on a phone is a wall of
   text where there used to be a paragraph. Eight lines in this column is
   about what 48 words came out at. */
@media (max-width: 899px) {
  .row__desc { -webkit-line-clamp: 8; }
}

.row[hidden] { display: none; }

@media (hover: hover) {
  /*
   * The picture moves for the two things that go to the project — the picture
   * itself and the title — and for nothing else.
   *
   * It was .row:hover, so crossing the credits or the description pushed the
   * image in, promising a click that those words do not carry. The hover now
   * marks what is actually clickable.
   *
   * :has() is doing the work a parent selector would: the trigger is a child,
   * the thing that moves is a sibling of that child's container.
   */
  .row:has(.row__link:hover) .row__img,
  .row:has(.row__title a:hover) .row__img { transform: scale(1.03); }
}

/* No underline under a title on hover, here or on the cards.

   The picture pushing in is the affordance, and it fires from the title as
   well as from the picture — the rule above is why — so the underline was a
   second answer to a question already answered. Headings on this site are set
   large; an underline arriving under one of them is a heavier event than it
   is under a line of body copy, and it made an archive of work read as a list
   of links.

   Body links keep theirs. The distinction is deliberate: a heading that leads
   somewhere is a whole card or row you can point at, and a word inside a
   sentence has nothing but the underline to mark it.

   Keyboard focus is untouched — :focus-visible draws a ring, which is the
   affordance that was doing real work for anyone not using a mouse.
   ------------------------------------------------------------------------ */

/* --- play control ------------------------------------------------------

   A triangle in the middle of the film, on the picture, where every player
   anyone has used puts it.

   It replaces the underlined word "Play" that used to close the text. That
   word was chosen when the film was the only picture on the page and a drawn
   triangle would have competed with it; once a project can carry several
   films, three identical words down the column name none of them, while a
   control sitting on a film is unambiguous about which one it starts.

   The disc is dark rather than tinted so it reads on any frame — these sit on
   photography, and a light control disappears into a bright one.
   ------------------------------------------------------------------------ */

.video__play {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  width: 100%;
  border: 0;
  background: transparent;
  color: #fff;
  cursor: pointer;
}

.video__icon {
  display: grid;
  place-items: center;
  /* 64px target, comfortably over the 24px minimum and the 44px guideline. */
  width: 64px;
  height: 64px;
  border-radius: 50%;
  background: rgba(0, 0, 0, 0.55);
  transition: transform var(--dur) ease, background var(--dur) ease;
}

@media (hover: hover) {
  .video__play:hover .video__icon { transform: scale(1.06); background: rgba(0, 0, 0, 0.75); }
}

/* The frame is the only thing between the control and the page, so the ring
   goes inside it rather than around the whole picture. */
.video__play:focus-visible { outline: none; }
.video__play:focus-visible .video__icon {
  outline: 3px solid #fff;
  outline-offset: 3px;
}

/* --- 1. even rhythm ----------------------------------------------------- */

.rows--even { --step: clamp(72px, 10.5vw, 156px); gap: var(--step); }
.rows--even .row { align-items: start; }

@media (min-width: 900px) {
  .rows--even .row { grid-template-columns: repeat(12, 1fr); column-gap: var(--space-m); }
  .rows--even .row__media { grid-column: 1 / span 7; }
  .rows--even .row__text  { grid-column: 8 / span 5; }
  .rows--even .row:nth-child(even) .row__media { grid-column: 2 / span 7; }
  .rows--even .row:nth-child(even) .row__text  { grid-column: 9 / span 4; }
}

/* --- 2. even rhythm 2 --------------------------------------------------- */

.rows--even2 { --step: clamp(72px, 10.5vw, 156px); gap: var(--step); }
.rows--even2 .row { align-items: start; }

@media (min-width: 900px) {
  /*
   * Three tracks: the picture, the text, and whatever is left.
   *
   * This was a twelve-column grid with the picture holding six of them. That
   * worked while every picture filled its six, and stopped the moment the
   * height ceiling began shrinking the taller shapes: the track stayed six
   * columns wide, the picture got narrower inside it, and the difference
   * showed as dead space before the text. Measured across the archive, the
   * gap ran from 34px on a frame that still filled its track to 202px on a
   * 4:3 — the same fault the portrait row had, on most of the page.
   *
   * The picture's track is now the picture's own width, so the text always
   * starts one gap after it, whatever shape it is. The twelve-column measures
   * survive as the two limits that mattered: a picture may still be six
   * columns at most, which is Rising Up's width, and the text keeps its
   * five-column measure rather than stretching into the space a narrow
   * picture frees up.
   *
   * The rhythm's indent survives, in a leading track of its own; the note on
   * it below says why it cannot be a margin.
   */
  .rows--even2 .row {
    --col: calc((100% - 11 * var(--space-m)) / 12);
    --row-step: calc(var(--col) + var(--space-m));
    --row-pic-max: calc(var(--col) * 6 + 5 * var(--space-m));
    --row-text: calc(var(--col) * 5 + 4 * var(--space-m));
    --row-indent: 0px;

    /* The indent is a track of its own, not a margin on the picture. As a
       margin it came out of the picture's track, so the same shape rendered
       at a different size depending where it fell in the cycle — two 4:3
       frames measured 548px and 451px. A track leaves the picture alone.

       column-gap is 0 because the indent track must sit flush against the
       picture; the one gap that should exist is a margin on the text. */
    grid-template-columns:
      var(--row-indent)
      min(var(--row-pic-max), calc(var(--row-max-h-own) * var(--row-ar, 1.5)))
      minmax(0, var(--row-text))
      minmax(0, 1fr);
    column-gap: 0;
  }

  .rows--even2 .row__media { grid-column: 2; }
  .rows--even2 .row__text  { grid-column: 3; margin-left: var(--space-m); }

  /* The row is as tall as its picture, and the text cannot make it taller.

     This is what stops the measured cut in app.js from moving the page. A
     grid row is as tall as its tallest cell, so while the text could outgrow
     the picture, the row's height depended on how much text survived the cut
     — and the cut is decided in JavaScript, after the fonts land. Measured at
     920px wide, ten rows shortened by 56–112px each when it ran, collapsing
     the page by 929px. Above about 1200px the picture was always the taller
     of the two, which is why it measured zero there and I did not catch it.

     Capping the text at the picture's own height takes the question away from
     JavaScript entirely: the height is known from the stylesheet at first
     paint, whatever the fit works out afterwards. The fit still earns its
     keep — it is what makes the cut land on a whole line rather than slicing
     one — but it can only ever change what shows inside a box whose size is
     already settled.

     The picture's height, derived the same way its width is: a frame is
     min(six columns, ceiling x ratio) wide, so it is min(six columns / ratio,
     ceiling) tall.

     The floor is for the rows where that is very short — a narrow upright
     frame beside a long title — so the title and credits are never clipped to
     fit a thumbnail. It is a constant, so those rows are deterministic too. */
  /* The picture decides the row's height; the text is told what it got.

     My first try at this derived the picture's height arithmetically and set
     it on the text — and it was wrong twice over. --row-pic-max is built from
     100%, which means the container's width in grid-template-columns and its
     HEIGHT in a height property, so the value came out meaningless; and as a
     max-height it still let the text grow inside the floor, leaving 204px of
     movement at 1280px. Measuring caught both.

     contain: size asks the browser the same question with none of the
     arithmetic: the text is sized as though it were empty, so it contributes
     nothing to how tall the row is. That leaves the picture as the only thing
     that does, and stretch then hands the text exactly that height. No
     percentage to misread, no number to keep in step with the width rule
     above, and the height is settled at first paint whatever the fit decides
     afterwards.

     min-height is the floor for a short picture beside a long title, so the
     title and credits are never clipped to fit a thumbnail. */
  .rows--even2 .row { align-items: stretch; min-height: 13rem; }
  .rows--even2 .row__media { align-self: start; }
  .rows--even2 .row__text  { contain: size; overflow: hidden; }

  /* The six-step cycle, unchanged: three starting points down the page. */
  .rows--even2 .row:nth-child(6n+2) { --row-indent: calc(2 * var(--row-step)); }
  .rows--even2 .row:nth-child(6n+3) { --row-indent: var(--row-step); }
  .rows--even2 .row:nth-child(6n+5) { --row-indent: var(--row-step); }
  .rows--even2 .row:nth-child(6n+6) { --row-indent: calc(2 * var(--row-step)); }
}

/* The text column is narrower here than in Even rhythm, so it needs more air
   inside it or the credits and description crowd the title. One step up on
   each gap, from the same scale. */
.rows--even2 .row__title   { margin-bottom: var(--space-m); }
.rows--even2 .row__credits { margin-bottom: var(--space-m); line-height: var(--lh-loose); }
.rows--even2 .row__credits { gap: var(--space-xs); }
.rows--even2 .row__desc    { max-width: 42ch; }

/* --- 3. image-led ------------------------------------------------------- */

.rows--image { gap: clamp(108px, 16.5vw, 240px); }
.rows--image .row { gap: var(--space-s); }

@media (min-width: 900px) {
  .rows--image .row { grid-template-columns: repeat(12, 1fr); }
  .rows--image .row__media,
  .rows--image .row__text { grid-column: 1 / span 9; }
  .rows--image .row:nth-child(even) .row__media,
  .rows--image .row:nth-child(even) .row__text { grid-column: 4 / span 9; }

  /* Play sits on the heading's own line, at the right end. Placed with grid
     rows rather than by reordering the markup, so reading order stays
     title, credits, description, control. */
  .rows--image .row__text {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    column-gap: var(--space-m);
  }
  .rows--image .row__title { grid-column: 1; grid-row: 1; }
  .rows--image .row__credits { grid-column: 1 / -1; grid-row: 2; }
  .rows--image .row__desc { grid-column: 1 / -1; grid-row: 3; }
}

/* Larger: the only control in a layout that gives each project a full page. */
.rows--image .row__desc { max-width: 58ch; }

/* --- 4. locked measure -------------------------------------------------- */

.rows--measure { --lh: 1.6rem; gap: calc(var(--lh) * 6); }
.rows--measure .row { gap: var(--lh); align-items: start; }

@media (min-width: 900px) {
  .rows--measure .row {
    grid-template-columns: minmax(0, 1fr) 38ch;
    column-gap: calc(var(--lh) * 1.5);
  }
}

.rows--measure .row__title { margin-bottom: var(--lh); }
.rows--measure .row__credits {
  margin-bottom: var(--lh);
  padding-top: calc(var(--lh) / 2);
  border-top: 1px solid var(--line);
}
.rows--measure .row__desc { line-height: var(--lh); max-width: none; }

/* --- filters and role nav ----------------------------------------------

   Selected reads by three things at once — colour, weight and a rule under it
   — because a single underline is ambiguous next to links that are themselves
   underlined. Unselected sits muted, so the current filter is the only one at
   full contrast.
   ----------------------------------------------------------------------- */

.filters__list,
.rolenav__list {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gap);
  list-style: none;
  margin: 0 0 var(--space-m);
  padding: 0;
  font-size: var(--fs-meta);
  line-height: var(--lh-snug);
}

.filters__link,
.rolenav__list a {
  display: inline-block;
  padding-block: var(--space-xs);
  color: var(--ink-muted);
  text-decoration: none;
  border-bottom: 1px solid transparent;
  transition: color var(--dur) ease, border-color var(--dur) ease;
}

.filters__link.is-active,
.rolenav__list a[aria-current] {
  color: var(--ink);
  font-weight: 700;
  border-bottom-color: var(--ink);
}

@media (hover: hover) {
  .filters__link:hover,
  .rolenav__list a:hover {
    color: var(--ink);
    border-bottom-color: var(--line);
  }
  .filters__link.is-active:hover,
  .rolenav__list a[aria-current]:hover { border-bottom-color: var(--ink); }
}

.filters__link:focus-visible,
.rolenav__list a:focus-visible { color: var(--ink); }

/* --- credits ------------------------------------------------------------ */

.credits {
  display: grid;
  grid-template-columns: max-content 1fr;
  column-gap: var(--gap);
  row-gap: var(--space-xs);
  margin: var(--space-m) 0 0;
  font-size: var(--fs-meta);
  line-height: var(--lh-snug);
  max-width: 46rem;
}

.credits__label { color: var(--ink-muted); margin: 0; }
.credits__value { margin: 0; }

.project__body { margin-top: var(--space-m); }

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

/* The frame is the film's shape, written per project by jw_video_facade().
   16:9 only as the fallback — a 9:16 vertical in a landscape box is mostly
   crop, and seven of these projects are not 16:9. */
.video {
  position: relative;
  aspect-ratio: var(--video-ar, 16 / 9);
  overflow: hidden;
  background: #000;
}

.video__poster {
  width: 100%;
  height: 100%;
  object-fit: cover;
}
.video__poster--empty { background: var(--line); }

/* --- the featured frame fits on the screen ------------------------------

   The frame takes the poster's shape, which is right until the poster is
   nearly square: The Class of TASS has a 1:1 still, and at the full content
   width that is a 1312px square — most of it below the fold, with the play
   control somewhere off the bottom of the screen.

   So the frame is the largest box that keeps its shape AND fits the window.
   Expressed as a width calculated from the height it may have, because height
   with aspect-ratio and a definite width fights itself: the box keeps the
   width and quietly abandons the ratio. Here the ratio is never in doubt.

   85vh rather than a round 80: at a typical window a 16:9 film still comes out
   full width, so the common case is untouched and only the tall shapes are
   brought in. On a short window everything is brought in, which is the point.
   ------------------------------------------------------------------------ */

.project__video .video {
  width: min(100%, calc(85vh * var(--video-ar, 1.7778)));
  /* Left, not centred: once the frame is narrower than the column it should
     still start on the same line as the title and the text below it. Centring
     put a near-square still in the middle of the page with the type hanging
     off its left edge. */
  margin-inline: 0 auto;
  /* The shape can change when the film turns out not to match its poster —
     see the crossfade below — so it is worth easing rather than jumping. */
  transition: aspect-ratio var(--dur) ease, width var(--dur) ease;
}

/* --- poster out, film in -------------------------------------------------

   The player used to replace the poster outright, which is a hard cut from a
   photograph to a black rectangle. Now both are in the frame at once and the
   poster fades off it, so the film arrives rather than interrupting.

   Everything printed on the poster goes with it — the triangle and the
   duration — or they would sit over the playing film.

   Under prefers-reduced-motion --dur is 1ms, so this becomes the cut it was
   before without a separate rule.
   ------------------------------------------------------------------------ */

.video__poster,
.video__play,
.video__duration {
  transition: opacity var(--dur) ease;
}

.video.is-playing .video__poster,
.video.is-playing .video__play,
.video.is-playing .video__duration {
  opacity: 0;
  pointer-events: none;
}

/* Starts invisible and fades up over the frame's own black, so a film whose
   shape differs from the poster's never shows a torn edge mid-fade. */
.video__player {
  opacity: 0;
  transition: opacity var(--dur) ease;
}

.video.is-playing .video__player { opacity: 1; }

.video__duration {
  position: absolute;
  right: var(--space-s);
  bottom: var(--space-s);
  padding: var(--space-xs) var(--space-s);
  background: rgba(0, 0, 0, 0.65);
  color: #fff;
  font-size: var(--fs-meta);
  line-height: var(--lh-snug);
}

/* An uploaded film fills the frame the same way a provider's iframe does. The
   frame is already the film's own shape — taken from the file's dimensions —
   so "contain" letterboxes nothing in practice and protects the one case where
   the shape is unknown and the frame falls back to 16:9. */
.video iframe,
.video video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border: 0;
}

.video video {
  object-fit: contain;
  background: #000;
}

/* --- single project ---------------------------------------------------- */

.project__header { margin-bottom: var(--space-m); }
.project__title  { margin-bottom: var(--space-s); }
.project__meta   { font-size: var(--fs-meta); line-height: var(--lh-snug); color: var(--ink-muted); }

/* The film opens the page now, above the title. Nothing on top, so it starts
   exactly where the title used to — the page's own top padding sets that — and
   the wider gap goes underneath, where a full-width frame meets a line of text
   and needs more air than two blocks of text would. */
.project__video  { margin: 0 0 var(--space-l); }

/* --- more videos ------------------------------------------------------- */

.videos { margin-top: var(--section-pad); }

.videos__list {
  display: grid;
  gap: var(--gap-row) var(--gap);
  margin: 0;
  padding: 0;
  list-style: none;
  /* Frames take each film's own shape, so a vertical episode beside a
     landscape one is taller. Top-aligned, the captions of a row stay level
     with their own pictures rather than being pushed apart to match. */
  align-items: start;
}

/*
 * Four columns, and a film takes one or two of them.
 *
 *   landscape (16:9 and anything wider than tall)   two columns
 *   vertical  (a Short, a Reel, a 9:16 social cut)  one column
 *
 * Which gives the packing Jamie asked for, out of ordinary grid flow with no
 * special cases: one or two verticals leave room for a landscape film beside
 * them (1+2 and 2+2 both fit in four), while three or four fill the row and
 * push it to the next line (3+2 and 4+2 do not fit).
 *
 * A landscape film is still exactly half the page: two of four columns plus
 * the gap between them is (100% - gap) / 2, the same width it had when this
 * was a two-column grid. That is also the line the footer aligns to, so the
 * footer did not have to change.
 *
 * A vertical film needs no height cap here. It is a quarter of the width, so
 * a 9:16 comes out around 550px tall against a landscape neighbour's 363 —
 * the cap existed when a vertical took half the page and ran to 1143px.
 */
@media (min-width: 700px) {
  .videos__list { grid-template-columns: repeat(4, minmax(0, 1fr)); }

  .videos__item { grid-column: span 2; }
  .videos__item--portrait { grid-column: span 1; }
}

.videos__caption {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--space-s);
  margin: var(--space-s) 0 0;
}

.videos__title {
  font-size: var(--fs-body);
  line-height: var(--lh-snug);
}


/* --- role nav ---------------------------------------------------------- */

.rolenav__list {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gap);
  list-style: none;
  margin: 0 0 var(--space-m);
  padding: 0;
  font-size: var(--fs-meta);
  line-height: var(--lh-snug);
}

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

.footer {
  padding-block: var(--section-pad);
  border-top: 1px solid var(--line);
  font-size: var(--fs-meta);
  line-height: var(--lh-snug);
  color: var(--ink-muted);
}

.footer__grid {
  display: grid;
  grid-template-columns: 1fr;
  column-gap: var(--gap);
  row-gap: var(--space-m);
}

/*
 * The footer sits on the same lines as the videos grid above it.
 *
 *   column 1  the full width of a landscape film
 *   column 2  starts where the second landscape film would start
 *   column 3  starts halfway across that second film
 *
 * A landscape film spans two of the videos grid's four columns, which comes to
 * (100% - gap) / 2 — half the page, whatever the column count. So these numbers
 * did not change when the grid went from two columns to four.
 *
 * calc() rather than 2fr 1fr 1fr: fr shares out what is left after BOTH gaps,
 * which makes column 1 half a gap narrower than a film and puts every edge
 * after it 12px off. Column 2 gives back one gap so column 3 lands on the
 * halfway line exactly.
 *
 * Same breakpoint as the videos grid, 700px — the lines only exist once the
 * films are side by side. Below that everything stacks.
 */
@media (min-width: 700px) {
  .footer__grid {
    grid-template-columns:
      calc((100% - var(--gap)) / 2)
      calc((100% - var(--gap)) / 4 - var(--gap))
      minmax(0, 1fr);
  }

  /* Pinned rather than auto-placed. Each column is left out when its Site
     settings are empty, and auto-placement would then slide the next one into
     the wrong track — Elsewhere landing on the first film's line. */
  .footer__col--contact   { grid-column: 1; }
  .footer__col--elsewhere { grid-column: 2; }
  .footer__col--social    { grid-column: 3; }
}

.footer__list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.footer__list--stacked li + li { margin-top: var(--space-xs); }

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

.footer__legal {
  margin-top: var(--space-m);
}

/* --- about -------------------------------------------------------------- */

.about__grid {
  display: grid;
  grid-template-columns: 1fr;
  column-gap: var(--gap);
  row-gap: var(--space-m);
  align-items: start;
}

@media (min-width: 900px) {
  /* Portrait column sized so the 2:3 image lands near a third of the measure,
     leaving the prose a comfortable line length beside it. */
  .about__grid { grid-template-columns: minmax(220px, 34%) 1fr; }
}

.about__img {
  /* No forced ratio: the portrait keeps its own proportions. Anything else
     crops a person's head, which is the one place cover-cropping never works. */
  width: 100%;
  height: auto;
}

/* --- prose (About, and nothing else) ----------------------------------- */

.prose { max-width: 62ch; }
.prose p + p { margin-top: var(--space-s); }

/* --- target size (WCAG 2.5.8) ------------------------------------------- */

/* Nav, footer, role-nav and pagination links are list items, not prose, so
   they don't get 2.5.8's inline exception. Measured at 19-22px tall before
   this; --space-xs top and bottom takes every one of them past 24px without
   changing how anything looks. */

.nav__list a,
.rolenav__list a,
.footer__list a,
.pagination .page-numbers {
  display: inline-block;
  padding-block: var(--space-xs);
}

.pagination .page-numbers {
  min-width: 24px;
  text-align: center;
}

/* Fallback for browsers without cross-document view transitions (Firefox at
   time of writing). The incoming page fades in on load — no fade-out, because
   a fade-out has to block navigation to be seen, and blocking is exactly the
   stall we removed. Never delays a click. */

@supports not (view-transition-name: none) {
  @media (prefers-reduced-motion: no-preference) {
    body {
      /* Same duration and curve as the real thing, so Firefox is not on a
         different rhythm from Chrome and Safari. */
      animation: oof-page-in var(--dur-page) ease-in-out both;
    }
  }
}

@keyframes oof-page-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* --- stills --------------------------------------------------------------

   Columns rather than the projects' broken grid. Both preserve each image's
   own proportions; the difference is packing. The grid places into fixed
   column spans, so a short frame leaves the space under it empty — with 88
   photographs that measured 72% filled and 15,800px tall. Columns pack
   vertically: 90% and 8,900px, with no holes to explain away.

   The grid stays right for the archive, where a dozen cards carry titles and
   credits and the air is doing work. Here there is nothing but pictures.
   ------------------------------------------------------------------------ */

.stills {
  list-style: none;
  margin: 0;
  padding: 0;
  column-count: 1;
  column-gap: var(--gap);
}

@media (min-width: 700px)  { .stills { column-count: 2; } }
@media (min-width: 1100px) { .stills { column-count: 3; } }

.still {
  /* A column box can fragment its children across columns; an image would be
     sliced in half. break-inside keeps each photograph whole. */
  break-inside: avoid;
  margin: 0 0 var(--gap);
  /* Belt and braces for the same thing in older engines. */
  display: inline-block;
  width: 100%;
}

.still__img {
  display: block;
  width: 100%;
  height: auto;
}

/* --- project gallery -----------------------------------------------------

   Two layouts over one set of markup. The switch is a class on the list, so
   toggling re-flows what is already there — nothing re-renders and no image
   is fetched twice.

   Random is the default and is the broken grid: each frame keeps its own
   aspect ratio and takes a span derived from that ratio, which is the argument
   from the call — a ratio is chosen for the material, so showing them varied
   says a decision was made. Grid is three even 16:9 columns for scanning.
   ------------------------------------------------------------------------ */

.gallery { margin-top: var(--section-pad); }

.gallery__bar {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-s) var(--gap);
  align-items: baseline;
  justify-content: flex-end;
  margin-bottom: var(--space-m);
  padding-bottom: var(--space-s);
  border-bottom: 1px solid var(--line);
}

.gallery__heading { margin: 0; }

.gallery__modes { display: flex; gap: var(--gap); }
.gallery__modes[hidden] { display: none; }

/* Same three-signal treatment as the category filters — colour, weight and a
   rule — so "selected" reads the same way everywhere on the site. */
.gallery__mode {
  padding: var(--space-xs) 0;
  border: 0;
  border-bottom: 1px solid transparent;
  background: transparent;
  color: var(--ink-muted);
  font-family: inherit;
  font-size: var(--fs-meta);
  line-height: var(--lh-snug);
  cursor: pointer;
  transition: color var(--dur) ease, border-color var(--dur) ease;
}

.gallery__mode[aria-pressed="true"] {
  color: var(--ink);
  font-weight: 700;
  border-bottom-color: var(--ink);
}

@media (hover: hover) {
  .gallery__mode:hover { color: var(--ink); border-bottom-color: var(--line); }
  .gallery__mode[aria-pressed="true"]:hover { border-bottom-color: var(--ink); }
}

.gallery__items {
  display: grid;
  column-gap: var(--gap);
  row-gap: var(--gap);
  grid-template-columns: 1fr;
  list-style: none;
  margin: 0;
  padding: 0;
}

.gallery__item { margin: 0; }
.gallery__link { display: block; line-height: 0; }
.gallery__img { width: 100%; height: auto; }

@media (hover: hover) {
  .gallery__link { transition: opacity var(--dur) ease; }
  .gallery__link:hover { opacity: 0.86; }
}

/* --- random: the editorial layout --------------------------------------

   The default, and the one that makes the argument from the call: a ratio is
   chosen for the material, so the frames should not be forced level with each
   other.

   Two things vary, not one. **Width** comes from the image's own ratio, as it
   does in the archive — an anamorphic frame earns more columns than a portrait.
   **Left edge** comes from a fixed six-step cycle of starts, the same device as
   Even rhythm 2 on the role pages, so the column the eye follows keeps moving
   without ever repeating on a short loop. Deterministic, so it never reshuffles
   between loads; long enough that it does not read as a pattern.

   Starts are capped at 4 and spans at 9, so the widest frame at the deepest
   indent still ends exactly on line 13 and nothing overflows the grid.

   The row gap is much larger than the column gap on purpose. That asymmetry is
   what separates a photo essay from a contact sheet: each frame gets air above
   and below and sits as its own statement, rather than tiling.
   ------------------------------------------------------------------------ */

@media (min-width: 700px) {
  .gallery__items--random {
    grid-template-columns: repeat(12, 1fr);
    row-gap: calc(var(--gap) * 2.5);
  }

  /* Width from the material. */
  .gallery__items--random .shot--wide   { --span: 9; }
  .gallery__items--random .shot--mid    { --span: 7; }
  .gallery__items--random .shot--narrow { --span: 5; }
  .gallery__items--random .shot--tall   { --span: 4; }

  .gallery__items--random .gallery__item {
    grid-column: var(--start, 1) / span var(--span, 6);
  }

  /* Left edge on a six-step cycle: 1, 3, 2, 4, 2, 1. */
  .gallery__items--random .gallery__item:nth-child(6n + 1) { --start: 1; }
  .gallery__items--random .gallery__item:nth-child(6n + 2) { --start: 3; }
  .gallery__items--random .gallery__item:nth-child(6n + 3) { --start: 2; }
  .gallery__items--random .gallery__item:nth-child(6n + 4) { --start: 4; }
  .gallery__items--random .gallery__item:nth-child(6n + 5) { --start: 2; }
  .gallery__items--random .gallery__item:nth-child(6n + 6) { --start: 1; }

  /* A drop on every third frame, so the vertical rhythm breaks too and the
     page does not settle into bands. */
  .gallery__items--random .gallery__item:nth-child(3n + 2) { margin-top: var(--space-l); }
}

/* --- grid: three even 16:9 columns ------------------------------------- */

@media (min-width: 700px)  { .gallery__items--grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1000px) { .gallery__items--grid { grid-template-columns: repeat(3, 1fr); } }

/* The one place on this site that crops deliberately. Everywhere else the
   image keeps its own ratio; here an even grid is the entire point, and a
   16:9 cell is what makes it even. */
.gallery__items--grid .gallery__item { grid-column: auto; margin-top: 0; }
.gallery__items--grid .gallery__img {
  aspect-ratio: 16 / 9;
  object-fit: cover;
}

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

   A <dialog>, so the browser supplies the modal semantics, the focus trap and
   Esc — none of which is worth reimplementing badly.
   ----------------------------------------------------------------------- */

.lightbox {
  padding: 0;
  border: 0;
  max-width: 100vw;
  max-height: 100vh;
  width: 100%;
  height: 100%;
  background: transparent;
  color: var(--ink);
  overflow: hidden;
}

.lightbox::backdrop { background: rgba(0, 0, 0, 0.92); }

.lightbox__stage {
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
  padding: clamp(44px, 7vw, 88px) clamp(12px, 5vw, 72px);
}

.lightbox__img {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  object-fit: contain;
}

.lightbox__btn {
  position: absolute;
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
  padding: 0;
  border: 0;
  background: transparent;
  /* Always on a photograph, never on the page, so these are literal white
     rather than tokens — the same reasoning as the video play control. */
  color: #fff;
  cursor: pointer;
  opacity: 0.75;
  transition: opacity var(--dur) ease;
}

.lightbox__btn:hover,
.lightbox__btn:focus-visible { opacity: 1; }
.lightbox__btn:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }

.lightbox__close { top: 6px; right: 6px; }
.lightbox__prev  { left: 4px;  top: 50%; transform: translateY(-50%); }
.lightbox__next  { right: 4px; top: 50%; transform: translateY(-50%); }

.lightbox__count {
  position: absolute;
  left: 50%;
  bottom: 14px;
  transform: translateX(-50%);
  color: #fff;
  opacity: 0.75;
  font-size: var(--fs-meta);
  font-variant-numeric: tabular-nums;
}

/* --- home: the statement ------------------------------------------------

   Replaces the showreel that used to open the page. Set larger than body copy
   but well under the display size: it is the first thing read, not a headline,
   and a producer scanning for a DOP should take it in at a glance without it
   shouting.
   ------------------------------------------------------------------------ */

/* The home page opens lower than the inner pages.
   
   --top-pad gives every template the same 38px under the header, which is right
   for a page that starts with a heading — the heading's own weight marks the
   start. The home page starts with a sentence at body-ish size, and 38px was
   not enough to separate it from the rule above: it read as attached to the
   header rather than as the start of the page.

   Derived, not picked: --section-pad is the space the site already uses between
   major blocks, and the header to the first line is exactly that kind of gap. */
.intro { max-width: 38em; margin-top: calc(var(--section-pad) - var(--top-pad)); }

/* Who he is. --fs-display-s, the same step the archive row titles use — bigger
   than everything around it without turning the sentence into a poster. At full
   display size it ran to four lines and read as a headline, which is the wrong
   register for a line of prose. */
.intro__text {
  margin: 0;
  font-size: var(--fs-display-s);
  line-height: var(--lh-snug);
  text-wrap: pretty;
}

/* The proof. Body size and muted, so it sits under the statement rather than
   beside it — someone who has decided to keep reading finds the credits here. */
/* .intro__body p is the same thing written by the editor: the supporting lines
   are inner paragraph blocks now, so they arrive as plain <p> inside the body
   wrapper rather than carrying the class themselves. */
.intro__sub,
.intro__body p {
  margin: var(--space-m) 0 0;
  font-size: var(--fs-body);
  line-height: var(--lh-loose);
  color: var(--ink-muted);
  max-width: 58ch;
}

.intro__more {
  margin: var(--space-m) 0 0;
  font-size: var(--fs-meta);
  color: var(--ink-muted);
}

.intro__more a { color: inherit; }

@media (hover: hover) {
  .intro__more a:hover { color: var(--ink); }
}

/* --- the frame around project images -------------------------------------

   2px of --ink: black on the cream, near-white in dark mode, following the
   theme rather than being hard-coded either way.

   A border, not an outline, for two reasons. Outline is already the focus ring
   on this site, and a permanent outline on every image would make the keyboard
   focus indicator invisible on exactly the elements most likely to receive it.
   And with box-sizing: border-box the border is drawn inside the box, so
   nothing moves and no grid column changes width — an outline would have been
   clipped by the overflow: hidden these containers need anyway.
   ------------------------------------------------------------------------ */

.card__media,
.row__frame,
.project__video .video,
.videos .video {
  border: 2px solid var(--ink);
}

/* The empty placeholders keep the frame too, so a project with no image reads
   as one still missing its picture rather than as a gap in the layout. */
.card__img--empty,
.row__img--empty {
  border: 0;
}

/* Focus needs more room once the image has a permanent frame: the ring is the
   same 2px of --ink, so at the default 3px offset a focused card reads as two
   near-identical lines rather than as a state change. Pushing it out clears the
   frame and reads unmistakably as focus. */
.card__link:focus-visible,
.gallery__link:focus-visible {
  outline-offset: 6px;
}

/* A project with no film shows its featured image where the player would be. */
.project__still {
  display: block;
  width: 100%;
  height: auto;
  border: 2px solid var(--ink);
}

/* --- portrait film: text beside it --------------------------------------

   A 9:16 film at full width is a narrow column of picture with empty page each
   side and the credits pushed below the fold. Audi x Wilderness is the case at
   1445x2560.

   The film keeps its own shape and sits left; title, meta, description and
   credits stack to the right of it. Nothing is cropped — the room comes from
   the layout, not from the frame.
   ------------------------------------------------------------------------ */

@media (min-width: 900px) {
  .project--beside {
    display: grid;
    /* The film's column tracks the film. It was a fixed 34%, which was right
       when the frame was tall enough to fill it; shortening the frame left a
       200px void between the picture and the text with nothing in it. "auto"
       means the column is whatever the film comes out as at the height capped
       below, so the gap beside it is always the one gap value — and a squarer
       film, which comes out wider, simply takes more of the row. */
    grid-template-columns: auto minmax(0, 1fr);
    column-gap: var(--section-pad);
    align-items: start;
  }

  /* The film spans both rows on the left; everything else queues on the right. */
  .project--beside .project__video {
    grid-column: 1;
    grid-row: 1 / span 4;
    margin: 0;
    /* Left, not stretched: the frame is sized by its height below, so the
       column is wider than the film and the film sits at the start of it. */
    justify-self: start;
  }

  /* Height first, width derived.
     
     A 9:16 filling a 34% column ran to 694px tall — a tower of picture that
     pushed everything else down the page. Capping the height and letting
     aspect-ratio compute the width keeps the shape exactly and makes the whole
     thing much shorter. Works for any ratio: a squarer film simply comes out
     wider at the same height. */
  /* The featured frame only. Scoped through .project__video because this whole
     article is .project--beside whenever the featured film is portrait — which
     a YouTube Short now makes common — and an unscoped .video caught the More
     videos grid too, stretching every film in it to 92vh and throwing its
     shape away: a 9:16 measured 0.374 and a 16:9 measured 0.778. */
  .project--beside .project__video .video {
    /* Just under the screen. No pixel ceiling alongside it — the size is meant
       to be the viewport, so a px cap would just override the intent on a tall
       display, which is what the old min(46vh, 430px) did at 900px. */
    height: 92vh;
    width: auto;
    max-width: 100%;
  }

  .project--beside .project__header,
  .project--beside .project__body,
  .project--beside .project__credits,
  .project--beside .gallery {
    grid-column: 2;
  }

  /* The header already carries the page's top spacing; beside the film it wants
     to start level with it instead. */
  .project--beside .project__header { margin-top: 0; }

  /* A gallery under a 34% column would be a sliver, so it returns to full
     width beneath both. More videos likewise: two frames need the whole row. */
  .project--beside .videos,
  .project--beside .gallery {
    grid-column: 1 / -1;
    margin-top: var(--section-pad);
  }
}
