@charset "utf-8";

/* ==========================================================================
   CRINI™ — black and white only. Grays are opacity on --fg, never a hue.
   Artwork is the sole source of color on the site.
   ========================================================================== */

@font-face {
  font-family: "Inter";
  src: url("../fonts/inter-variable.woff2") format("woff2-variations");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

/* Variable, so the wordmark can be set bold. A single-weight file would render
   regular instead, silently — `font-synthesis-weight: none` below disables the
   faux-bold the browser would otherwise fake. */
@font-face {
  font-family: "JetBrains Mono";
  src: url("../fonts/jetbrains-mono-variable.woff2") format("woff2-variations");
  /* Must match the axis range fonts.py requests. The file really is clamped to
     400..700 (30.6KB, against 39.5KB for the full 100..800), so declaring a
     wider range here would promise weights the file cannot render. */
  font-weight: 400 700;
  font-style: normal;
  font-display: swap;
}

:root {
  --fg: #000;
  --bg: #fff;

  --sans: "Inter", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
  --serif: "EB Garamond", Iowan Old Style, Palatino, Georgia, serif;
  --mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;

  /* Metadata and legends. Tight, uppercase, tracked out. */
  --meta-size: 0.6875rem;      /* 11px */
  --meta-track: 0.04em;

  --gutter: clamp(1rem, 3vw, 2.5rem);
  /* Gap between the fixed header and the first content on every page. It is
     --gutter deliberately: a fixed 1.5rem against sides that scale with the
     viewport meant the top inset was larger than the sides on a phone and
     smaller on a desktop, matching only around 1000px. Kept as its own name
     because several rules subtract it by meaning — the project's image height
     cap, the stage gap, the arrow offset — and those read wrong as raw gutters. */
  --content-top: var(--gutter);
  --rule: 1px solid color-mix(in srgb, var(--fg) 15%, transparent);
  /* The two chrome bars carry a darker line than in-content separators. At the
     15% of --rule they read as almost nothing against the page, and the header
     stopped looking anchored once it was fixed over scrolling content. Content
     rules stay light on purpose — they separate, they should not divide.
     Weight is deliberately still 1px: only the colour differs. */
  --rule-strong: 1px solid color-mix(in srgb, var(--fg) 25%, transparent);

  /* Header and footer share this height so the two bars match exactly. It also
     sets how far down the project starts, so the header stays visible. */
  --bar-h: 3.5rem;

  --ease: cubic-bezier(0.2, 0, 0.2, 1);
  --reveal-dur: 300ms;

  color-scheme: light dark;
}

/* Theme resolution, in precedence order:
   1. system preference (this media query)
   2. an explicit [data-theme] set by the toggle, which must win in BOTH
      directions — hence a light override as well as a dark one, so choosing
      light on a dark-preferring OS actually sticks.
   The attribute is applied by an inline head script before first paint, so
   there is no flash of the wrong theme. */
@media (prefers-color-scheme: dark) {
  :root {
    --fg: #fff;
    --bg: #000;
  }
}

/* color-scheme must follow the explicit choice too, not just the custom
   properties. UA-rendered things — system colours like CanvasText, scrollbars,
   form controls — resolve against color-scheme, NOT against --fg/--bg. Leaving
   it as `light dark` meant choosing light on a dark OS gave black-on-white text
   in the page but white CanvasText inside <dialog>: invisible. */
:root[data-theme="dark"] {
  --fg: #fff;
  --bg: #000;

  color-scheme: dark;
}

:root[data-theme="light"] {
  --fg: #000;
  --bg: #fff;

  color-scheme: light;
}

/* Bold italic only — the single style in-prose links use. A variable italic would
   cost 46.7KB for weights nothing asks for. No preload in the shared head: this
   is used on /info alone, and a font is only fetched when something needs it, so
   the archive never pays for it. /info preloads it explicitly. */
@font-face {
  font-family: "EB Garamond";
  src: url("../fonts/eb-garamond-700-italic.woff2") format("woff2");
  font-weight: 700;
  font-style: italic;
  font-display: swap;
}

/* --------------------------------------------------------------------------
   Base
   -------------------------------------------------------------------------- */

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

html {
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  /* A short page — a one-line project, a 404 — left the footer floating with
     blank page beneath it. The column plus a full-viewport minimum pushes it to
     the bottom, and `main` below takes up the slack.
     box-sizing is border-box globally, so padding-top is inside the 100dvh. */
  display: flex;
  flex-direction: column;
  min-height: 100dvh;
  /* Compensates for the fixed header, which is out of flow. */
  padding-top: var(--bar-h);
  background: var(--bg);
  color: var(--fg);
  font-family: var(--sans);
  font-size: 1rem;
  font-weight: 400;
  line-height: 1.45;
  font-synthesis-weight: none;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

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

/* The UA stylesheet's `[hidden] { display: none }` is only a UA rule, so ANY
   author `display` on the same element beats it. `.controls__group` sets
   display:flex, which silently un-hid the overlay's back control on the archive —
   the attribute was set, the element was on screen. !important because the whole
   point is to outrank a later author rule that does not know about `hidden`. */
[hidden] {
  display: none !important;
}

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

ul {
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Visible only to screen readers, but focusable for skip links. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

:focus-visible {
  outline: 1px solid var(--fg);
  outline-offset: 2px;
}

/* --------------------------------------------------------------------------
   Typography
   -------------------------------------------------------------------------- */

/* Metadata and legends never take the prose margin set on `p` below. */
.meta,
.mono {
  margin: 0;
  font-family: var(--mono);
  font-size: var(--meta-size);
  font-weight: 400;
  letter-spacing: var(--meta-track);
  text-transform: uppercase;
  font-variant-numeric: tabular-nums;
}

h1,
h2,
h3 {
  margin: 0;
  font-weight: 500;
  letter-spacing: -0.015em;
  line-height: 1.15;
  text-wrap: balance;
}

h1 {
  font-size: clamp(1.5rem, 1.1rem + 1.8vw, 2.5rem);
}

h2 {
  font-size: clamp(1.125rem, 1rem + 0.6vw, 1.375rem);
}

p {
  margin: 0 0 1em;
  max-width: 62ch;
  text-wrap: pretty;
}

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

/* Header and footer are both --bar-h tall, with one hairline border each, so the
   two bars are exactly equal. Vertical padding is 0 — the min-height plus
   centred content sets the height, so it cannot drift out of sync with the
   footer. */
/* Fixed, not sticky. Sticky needs a scrollport, and the scroll-lock applied
   while a project is open (`overflow: hidden`) removes it — the header then
   dropped back to its static position, leaving a gap at the top of the viewport
   showing whatever the page had scrolled to. Fixed is immune to that, and keeps
   the header put whatever else is on screen.
   z-index above the project so it can never be covered. */
.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 120;
  display: flex;
  gap: var(--gutter);
  align-items: center;
  justify-content: space-between;
  min-height: var(--bar-h);
  padding: 0 var(--gutter);
  background: var(--bg);
  border-bottom: var(--rule-strong);
}

.site-header__brand {
  font-family: var(--mono);
  font-size: var(--meta-size);
  letter-spacing: var(--meta-track);
  text-transform: uppercase;
}

/* The wordmark is always bold, wherever it appears. Requires the variable mono
   above — with a single-weight file this would render regular. */
.wordmark {
  font-weight: 700;
}

.site-nav {
  display: flex;
  gap: 1.25rem;
  align-items: baseline;
}

.site-nav a {
  opacity: 0.4;
  transition: opacity 120ms var(--ease);
}

.site-nav a:hover,
.site-nav a:focus-visible,
.site-nav a[aria-current="page"] {
  opacity: 1;
}

/* Takes the slack in the body column, so the footer lands at the bottom of a
   short page rather than floating with blank space beneath it. */
main {
  flex: 1 0 auto;
}

.site-footer {
  display: flex;
  flex-wrap: wrap;
  gap: 1.25rem;
  align-items: center;
  justify-content: space-between;
  min-height: var(--bar-h);
  margin-top: 4rem;
  padding: 0 var(--gutter);
  border-top: var(--rule-strong);
}

.site-footer a {
  opacity: 0.4;
  transition: opacity 120ms var(--ease);
}

.site-footer a:hover,
.site-footer a:focus-visible {
  opacity: 1;
}

/* --------------------------------------------------------------------------
   The archive — one markup set, two views. Switching only swaps a class on the
   container, so already-loaded images are never re-requested.
   -------------------------------------------------------------------------- */

/* Image triggers are real buttons so the project is keyboard-reachable. */
.thumb-btn {
  display: block;
  width: 100%;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  /* Standard pointer, not zoom-in: it opens a project view, it does not magnify
     in place. */
  cursor: pointer;
  font: inherit;
}

/* --------------------------------------------------------------------------
   Floating controls, bottom right: view switch + theme switch.
   -------------------------------------------------------------------------- */

.controls {
  position: fixed;
  /* safe-area inset keeps these clear of the iOS home indicator. */
  right: max(var(--gutter), env(safe-area-inset-right));
  /* --lift is set by initControlLift(): the height of the footer currently in
     view, so the stack rides up over it instead of covering its links. Falls
     back to 0 without scripting, which is the old behaviour. */
  bottom: calc(max(var(--gutter), env(safe-area-inset-bottom)) + var(--lift, 0px));
  /* Above the project overlay (100), below the header (120). At the old z-index
     of 20 the dialog painted straight over the stack, so the theme toggle simply
     vanished whenever a project was open — the same class of bug as the header,
     and the reason the overlay is non-modal in the first place. */
  z-index: 110;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.5rem;
}

/* The bottom row: back control, then the actions group. Row gap is the same
   0.5rem the stack uses vertically, so the space left of the theme toggle
   matches the space between the view and action groups. */
.controls__row {
  display: flex;
  flex-direction: row;
  align-items: flex-end;
  gap: 0.5rem;
}

/* Browsing ONE project, so GRID, LIST and shuffle have nothing to act on —
   they operate on the archive behind the overlay. Only back and theme remain.
   Project PAGES never render them in the first place (with_view=False); this is
   for the overlay, which inherits the archive's own stack.
   Specificity beats `.controls__group { display: flex }` without !important. */
.project-open [data-view-toggle],
.project-open [data-shuffle] {
  display: none;
}

/* `display: none` does not stop `+` from matching — it is structural — so the
   theme toggle keeps the divider it wears under the shuffle button and renders a
   stray hairline across the top of its own box.
   `.controls__group` is in the selector only for specificity: the rule that adds
   the divider is `.controls__group .controls__btn + .controls__btn` (0,3,0) and
   it sits LATER in this file, so an equal-specificity override would lose. */
.project-open .controls__group [data-shuffle] + .controls__btn {
  border-top: 0;
}

.controls__group {
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: var(--bg);
  border: var(--rule);
  border-radius: 2px;
}

/* Hairline between stacked buttons, drawn only between them. */
.controls__group .controls__btn + .controls__btn {
  border-top: var(--rule);
}

.controls__btn {
  display: grid;
  place-items: center;
  width: 2.25rem;
  height: 2.25rem;
  padding: 0;
  border: 0;
  background: none;
  color: var(--fg);
  cursor: pointer;
  font: inherit;
  opacity: 0.45;
  transition: opacity 120ms var(--ease);
}

.controls__btn svg {
  fill: currentColor;
}

.controls__btn:hover {
  opacity: 0.75;
}

.controls__btn[aria-pressed="true"] {
  opacity: 1;
}

/* The theme button has no pressed state — it always acts, never "is on". */
.controls__btn[data-theme-toggle] {
  opacity: 0.65;
}

.controls__btn[data-theme-toggle]:hover {
  opacity: 1;
}

.works {
  /* Top padding replaces the removed works-count bar, which used to provide the
     gap below the header. Bottom padding keeps the last row clear of the
     floating controls. Both must live in this shorthand — a separate
     padding-bottom rule elsewhere would be overwritten by it. */
  padding: var(--content-top) var(--gutter) 5rem;
  transition: opacity 180ms var(--ease);
}

.works[data-switching="true"] {
  opacity: 0;
}

.work__title {
  font-weight: 500;
}

.work__tags,
.work__date {
  opacity: 0.55;
}

/* ---- GRID view (image-led, after Guido de Boer's archive) ---- */

.works--grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(min(16rem, 100%), 1fr));
  gap: 2.5rem var(--gutter);
}

/* A fixed column count once there is room. auto-fill alone keeps adding columns
   as the viewport grows — seven at 1920px — which shrinks every poster and makes
   the archive read as a contact sheet rather than a body of work. Below 64rem it
   falls back to auto-fill, which is right for phones and tablets.
   Four columns from 64rem, five from 81.25rem (1300px): five in the 1024-1300
   band leaves each poster too small to read. */
@media (width >= 64rem) {
  .works--grid {
    grid-template-columns: repeat(4, minmax(0, 1fr));
  }
}

@media (width >= 81.25rem) {
  .works--grid {
    grid-template-columns: repeat(5, minmax(0, 1fr));
  }
}

.works--grid .work {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
}

/* NO `content-visibility: auto` on the tiles, though 49 of them is exactly the
   case it exists for. Tried it with `contain-intrinsic-size: auto 22rem` and CLS
   went from 0 to 0.0186: tile heights vary with how each title wraps, so any
   single intrinsic guess is wrong for some tile and every correction shifts the
   grid. Trading a Core Web Vital the site currently scores perfectly for an
   unmeasured paint saving is a bad deal. `verify.py` fails on any CLS above 0,
   so re-adding this will be caught. */

.works--grid .work__thumb img {
  width: 100%;
  aspect-ratio: 4 / 5;
  object-fit: contain;
}

/* Title on its own line, then tags and date pushed to opposite edges so they
   read as two fields rather than one run of text. */
.works--grid .work__meta {
  display: flex;
  flex-wrap: wrap;
  gap: 0 1rem;
  align-items: baseline;
  justify-content: space-between;
}

.works--grid .work__title {
  flex: 1 1 100%;
  font-size: 0.875rem;
}

/* ---- LIST view (text-led rows, after Neubau's index) ---- */

/* The list is a chronological index: one row per project. Extra images of a
   multi-image project are hidden here because they are scattered through the
   grid — in a dated list they would break the date ordering AND repeat one
   project several times. The grid shows every image; the list stays an index.
   Both views still share ONE markup set; only this rule differs. */
.works--list .work[data-extra] {
  display: none;
}

/* Spans the full content width, so the row rules line up with the header and
   footer rules rather than stopping short of them. */
.works--list {
  display: block;
}

.works--list .work {
  display: grid;
  grid-template-columns: 5rem minmax(0, 1fr) minmax(0, 14rem) 5rem;
  gap: var(--gutter);
  align-items: center;
  padding: 0.5rem 0;
  border-bottom: var(--rule);
}

.works--list .work:first-child {
  border-top: var(--rule);
}

.works--list .work__thumb {
  width: 5rem;
}

.works--list .work__thumb img {
  width: 100%;
  aspect-ratio: 1 / 1;
  object-fit: contain;
}

.works--list .work__meta {
  display: contents;
}

.works--list .work__title {
  font-size: 0.9375rem;
}

.works--list .work__date {
  text-align: right;
}

/* The list row collapses to two columns on narrow screens: thumb, then a
   stacked title/tags/date block. */
@media (width < 40rem) {
  .works--list .work {
    grid-template-columns: 3.5rem minmax(0, 1fr);
    gap: 0.875rem;
    align-items: start;
  }

  .works--list .work__thumb {
    width: 3.5rem;
  }

  /* Stack the fields rather than promoting them to grid items — as grid items
     they distribute across the row's full height, stranding the date. */
  .works--list .work__meta {
    display: flex;
    flex-direction: column;
    gap: 0.125rem;
  }

  .works--list .work__date {
    text-align: left;
  }
}

/* --------------------------------------------------------------------------
   Info
   -------------------------------------------------------------------------- */

/* Content column, plus a side column for the portrait on wide screens. That
   space was empty, so the photo costs the prose no width at all — stacked above
   the bio it pushed every word below the fold on first load.
   Below 64rem the column collapses and the portrait falls after the content,
   which keeps text first on a phone for the same reason. */
.info-layout {
  display: grid;
  grid-template-columns: minmax(0, 60rem);
  gap: var(--gutter);
  align-items: start;
  padding: var(--content-top) var(--gutter) 0;
}

/* 50rem, not 60: measured, the widest content on the page reaches 49.6rem — the
   Practice list — so the last 10rem were never used. They go to the portrait.

   The floors are what decide who gives way when both cannot fit. Content has a
   28rem minimum and the portrait none, so the photo shrinks first. With those
   reversed — a floor on the portrait, none on the content — prose collapsed to
   293px at a 1024px viewport while the photo sat unmoved at its full 480. */
@media (width >= 64rem) {
  .info-layout {
    /* 1fr, not a fixed max: the track absorbs whatever is left so the figure can
       sit flush with the right edge of the content area — level with INFO in the
       header. The figure's own max-width stops it growing without limit on an
       ultrawide display, and justify-self keeps it against that edge. */
    grid-template-columns: minmax(28rem, 50rem) minmax(0, 1fr);
  }

  .portrait {
    justify-self: end;
    width: 100%;
    max-width: 40rem;
  }

  /* The portrait is FIRST in the DOM so it stacks above the content on a phone.
     Placing both explicitly puts it back on the right here, with the content on
     the left, independent of source order. */
  .info {
    grid-column: 1;
    grid-row: 1;
  }

  .portrait {
    grid-column: 2;
    grid-row: 1;
  }
}

.info {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  /* No row gap at any width: the space around a divider is the two sections'
     own padding and nothing else. This base rule kept a 3rem row gap after the
     wide-screen one was changed, which on a phone put 80px above each rule
     against 32 below — the same bug, one media query away. */
  gap: 0;
}

@media (width >= 48rem) {
  .info {
    /* 6rem, not 8: the widest label is EXHIBITIONS at 78px, so a 128px column
       held 50px of nothing. With the column gap at 1.5rem rather than --gutter's
       2.5rem, content now starts 120px into the section instead of 168px.
       The row gap is untouched — that one separates sections, and it should. */
    grid-template-columns: 6rem minmax(0, 1fr);
    /* No ROW gap: the space around a divider comes from the sections' own
       padding, above and below, so it is symmetric by construction rather than
       by two numbers that have to be kept in step. */
    gap: 0 1.5rem;
  }

  .info__section {
    display: grid;
    grid-column: 1 / -1;
    grid-template-columns: subgrid;
  }
}

.info__section {
  /* Equal above and below, which is what puts even space around each divider —
     this section's padding-top and the previous section's padding-bottom are the
     only things between them. */
  padding: 2rem 0;
  border-top: var(--rule);
}

/* Align the label with the first LINE of its content, not the top of its box.
   The label is 11px mono and the body 16px sans, so equal box tops leave the
   label visibly high. Baseline alignment is what the eye reads as level. */
@media (width >= 48rem) {
  .info__section {
    align-items: baseline;
  }
}

/* The first section sits directly under the header, whose bottom border already
   draws that line. A second rule 24px below it read as a stray double line. */
.info__section:first-of-type {
  padding-top: 0;
  border-top: 0;
}

/* (a) No ABOUT label on a phone. The label column collapses there, so it sits as
   a lone word above the bio it introduces — the bio opens with "I'm CRINI™" and
   needs no announcing. Every other section keeps its label: those genuinely name
   a list you would otherwise have to infer. */
@media (width < 48rem) {
  .info__section:first-of-type .info__label {
    display: none;
  }
}

.info__label {
  margin: 0 0 0.75rem;
  opacity: 0.55;
}

.info__body > :last-child {
  margin-bottom: 0;
}

/* Links inside prose need to look like links — the global rule strips
   underlines, which is right for navigation and wrong mid-sentence.
   The serif is reserved for this one link — Soulful, in the bio. Caption links
   were given it too and taken back: it is a flourish for the one personal
   mention, not a general in-prose treatment.
   Set in EB Garamond bold italic, which marks them as links by voice as well as
   by underline. The 1.08em compensates for the serif's smaller x-height, so it
   sits at the same optical size as the Inter around it. */
.info__body p a {
  font-family: var(--serif);
  font-weight: 700;
  font-style: italic;
  font-size: 1.08em;
  text-decoration: underline;
  text-decoration-color: color-mix(in srgb, var(--fg) 40%, transparent);
  /* EB Garamond italic has descending f and l strokes that collide with the
     underline. skip-ink cut around them and the rule rendered as three dashes,
     so skipping is off and the line is dropped clear of the descenders instead. */
  text-decoration-skip-ink: none;
  text-underline-offset: 0.22em;
  transition: text-decoration-color 120ms var(--ease);
}

.info__body p a:hover,
.info__body p a:focus-visible {
  text-decoration-color: currentColor;
}

.info__list {
  columns: 2;
  column-gap: var(--gutter);
}

.info__list li {
  break-inside: avoid;
}

/* Client name then location. The location is metadata, so it takes the mono
   treatment and sits quieter than the name. */
.info__list--clients li {
  display: flex;
  flex-wrap: wrap;
  gap: 0 0.5rem;
  align-items: baseline;
  padding: 0.125rem 0;
}

.info__list--clients a {
  text-decoration: underline;
  text-decoration-color: color-mix(in srgb, var(--fg) 30%, transparent);
  text-underline-offset: 3px;
  transition: text-decoration-color 120ms var(--ease);
}

.info__list--clients a:hover,
.info__list--clients a:focus-visible {
  text-decoration-color: currentColor;
}

.info__list--clients .meta {
  opacity: 0.5;
}

.info__list--linear {
  columns: 1;
}

/* Two columns of a 13-item roster are unreadable on a phone. One column there —
   which also makes document order the only adjacency, and arrange_clients()
   keeps matching locations apart in that order too. */
@media (width < 48rem) {
  .info__list {
    columns: 1;
  }
}

.info__list--linear li {
  display: grid;
  grid-template-columns: 4.5rem minmax(0, 1fr);
  gap: 0.75rem;
  padding: 0.375rem 0;
  border-bottom: var(--rule);
  /* Every row aligns to the TOP of the rail, so a date reads level with the
     first line of its entry whether that entry wraps or not.
     This line lives here on purpose: it was briefly inside the :last-child rule
     below, which left the final row of every list — and only that row — aligned
     differently from its neighbours. */
  align-items: start;
}

/* The section divider comes straight after, so a rule on the last row read as a
   double line. */
.info__list--linear li:last-child {
  border-bottom: 0;
}

/* Solo / Collective sits UNDER the date in the rail. Trailing the title, it
   wrapped onto its own line whenever the title filled the row, which read as a
   stray word rather than as a tag. */
.show__rail {
  display: flex;
  flex-direction: column;
  gap: 0.125rem;
}

.show__kind {
  opacity: 0.5;
}

.info__list--press a {
  text-decoration: underline;
  text-decoration-color: color-mix(in srgb, var(--fg) 30%, transparent);
  text-underline-offset: 3px;
  transition: text-decoration-color 120ms var(--ease);
}

.info__list--press a:hover,
.info__list--press a:focus-visible {
  text-decoration-color: currentColor;
}

/* Contact values are mono and uppercased, a step larger than the mono labels
   beside them so value and label stay visually distinct. text-transform, NOT
   uppercase in the markup: the DOM keeps the real lowercase address, so the
   mailto: and anything copied from the page are correct. */
.info__list--contact li > :last-child,
.info__list--contact .email a {
  font-family: var(--mono);
  font-size: 0.8125rem;
  letter-spacing: var(--meta-track);
  text-transform: uppercase;
}

/* --------------------------------------------------------------------------
   A project view. ONE set of rules serves two containers:
     - dialog.project        the instant overlay pushed by the client router
     - .project--page        the same view as a real document at /work/<slug>/
   Keeping them identical is the point: navigating between them must not look
   like anything changed.
   -------------------------------------------------------------------------- */

.project {
  /* dvh rather than vh, so mobile browser chrome cannot push it taller than the
     visible viewport. */
  --proj-h: calc(100dvh - var(--bar-h));
  /* Height ceiling for images in the stage. Overridden in the stacked layout
     below, where the metadata panel takes a share of the screen. */
  --proj-img-max-h: calc(var(--proj-h) - 2 * var(--content-top));

  /* Image left, metadata right. */
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 20rem);
  gap: var(--gutter);
  height: var(--proj-h);
  max-height: var(--proj-h);
  /* Equal top and bottom. It was --content-top over --gutter, so the image sat
     24px from the header and 40px from the footer — visibly off-centre in its
     own row. --proj-img-max-h above subtracts exactly this pair; change one and
     you must change both, or the image overflows the viewport. */
  padding: var(--content-top) var(--gutter);
  overflow: hidden;
  border: 0;
  background: var(--bg);
  /* Explicit, and not inherited: the UA stylesheet sets `color: CanvasText` on
     the dialog element itself, which beats any colour inherited from body. Omit
     this and the panel text follows the OS scheme while the background follows
     the theme — white on white the moment the two disagree. */
  color: var(--fg);
}

/* Overlay variant: covers the page but starts below the header, so the header
   stays visible AND clickable. A modal <dialog> could not do this — the top
   layer is hit-tested above everything — hence the non-modal dialog. */
dialog.project {
  position: fixed;
  top: var(--bar-h);
  right: 0;
  bottom: 0;
  left: 0;
  z-index: 100;
  width: 100vw;
  max-width: 100vw;
  margin: 0;
}

/* Page variant: the same view in normal flow at /work/<slug>/. Needs no
   positioning — body already carries padding-top for the fixed header. */
.project--page {
  width: 100%;
  /* Containing block for the stage arrows. dialog.project is already fixed and
     positions them itself; without this the page variant has no positioned
     ancestor, so they resolved against the viewport and the prev arrow sat
     half off the left edge of the screen. */
  position: relative;
}

/* No ::backdrop rule: a non-modal dialog renders none. It is not merely
   unnecessary — a backdrop covers the whole viewport regardless of the dialog's
   inset and routes clicks on that area to the dialog, which is what made the
   header unclickable. Do not reintroduce showModal(). */

/* Freeze the page behind while a project is open, so it cannot scroll away
   under the dialog. Needed because a non-modal dialog does not block scrolling
   the way a modal one does. */
.project-open,
.project-open body {
  overflow: hidden;
}

/* Setting `display: grid` above overrides the UA's `dialog:not([open])` rule,
   which would otherwise keep a closed dialog hidden. Restore it — scoped to
   `dialog`, or it would also hide .project--page, which has no `open`
   attribute and is not a dialog at all. */
dialog.project:not([open]) {
  display: none;
}

/* min-width/min-height 0 is load-bearing: a grid item defaults to `min-*: auto`,
   which floors it at the image's intrinsic size — so max-height on the image
   would be ignored and a large work would overflow the viewport. */
/* A plain scrolling block, not a grid. One image needs no layout algorithm, and
   when a project has several they should simply stack and scroll vertically —
   which this already supports without further change. */
/* Images sit side by side and scroll HORIZONTALLY, one per snap position. They
   used to stack and scroll vertically, which read as a long column for a project
   like SPIN Vol.8 that is really seven versions of one poster meant to be
   compared.

   `justify-content` is deliberately NOT used to centre a single image: on an
   overflowing flex scroller, centring pushes the first item past the scroll
   origin where it cannot be reached. The single-image case gets its own class
   instead — see .project__stage--single. */
/* Holds the stage plus its overlays. The arrows and the edge fade cannot live
   inside the scroller — they would scroll away with the images — and anchoring
   them to .project instead would put them against the metadata panel's edge
   rather than the image area's. */
.project__stage-wrap {
  position: relative;
  display: grid;
  min-width: 0;
  min-height: 0;
}

/* (c) A hairline on the right edge whenever the images overrun their container,
   marking where the row continues. This replaced a gradient: even eased, a fade
   either started as a visible band over the poster or never reached the page
   colour, and it dimmed the artwork either way. A rule states the boundary
   without touching the image.

   Drawn as a pseudo-element rather than a real border, so it costs no layout —
   a border on the wrap would shift the stage by its own width. The 1.5% bleed
   top and bottom lets it read as an edge of the row rather than a box side. */
.project__stage-wrap::after {
  content: "";
  position: absolute;
  top: -1.5%;
  bottom: -1.5%;
  right: 0;
  width: 0;
  z-index: 1;
  pointer-events: none;
  border-right: var(--rule-strong);
  opacity: 0;
  transition: opacity 200ms var(--ease);
}

/* Present whenever the row overruns, not only while there is more to the RIGHT:
   it marks the container's edge, so it should not blink out on reaching the end. */
.project__stage-wrap[data-overflow]::after {
  opacity: 1;
}

.project__stage {
  position: relative;
  display: flex;
  align-items: center;
  /* Same as the space above and below, so the rhythm around a poster is even in
     both axes rather than 24px vertically and 40px horizontally. */
  gap: var(--content-top);
  min-width: 0;
  min-height: 0;
  overflow-x: auto;
  overflow-y: hidden;
  overscroll-behavior: contain;
  /* PROXIMITY, not mandatory. With mandatory, a stage holding two posters each
     wider than half its width has a last snap point beyond the maximum scroll
     offset, and Chrome clamps to it — the stage opened already scrolled to the
     end, showing the last image with the back arrow lit. Proximity snaps when
     you let go near a point and never forces a position. */
  /* NO scroll snapping. It looks like the obvious tool here and it is a trap:
     the non-cover images are lazy, so at load the stage does not overflow, and
     when they arrive and the content grows the browser re-snaps and lands at the
     FAR END — the stage opened on the last image with the back arrow already
     lit. Both `mandatory` and `proximity` do it; bisected by toggling
     scroll-snap-type alone, which took scrollLeft from 368 back to 0.
     The arrows scroll to an exact offset anyway, so snapping bought nothing. */
  cursor: pointer;
  /* The scrollbar would sit under the image and flicker as it snaps. */
  scrollbar-width: none;
}

.project__stage::-webkit-scrollbar {
  display: none;
}

/* Breathing room after the last image, so reaching the end of the row does not
   leave a poster jammed against the edge rule. Inside the scroller, so it is
   scrollable space rather than a permanent inset — and matched to the gap and
   the vertical spacing, keeping one rhythm throughout.
   Excluded from the single-image case: there the stage centres its only child,
   and an end padding would shift it off-centre by half its width. */
.project__stage:not(.project__stage--single) {
  padding-right: var(--content-top);
}

/* One image: centre it, exactly as before. Safe here precisely because a single
   item never overflows, so the centring bug above cannot apply. */
.project__stage--single {
  justify-content: center;
}

@media (prefers-reduced-motion: no-preference) {
  .project__stage {
    scroll-behavior: smooth;
  }
}

.project__img {
  display: block;
  margin: 0;
  /* Bounded on both axes, so a work is never larger than the viewport.
     The height cap is an absolute dvh calc, NOT `max-height: 100%`: a percentage
     resolves against the parent's height, and against a stretched auto grid row
     Chrome treats that as indefinite and silently drops the constraint — only
     max-width applied and portrait works overflowed. An absolute value is
     definite in every layout context.
     width/height auto means the intrinsic size is the ceiling, so nothing is
     ever upscaled past its own resolution either. */
  width: auto;
  height: auto;
  max-width: 100%;
  max-height: var(--proj-img-max-h);
  /* Never let flex compress a poster to fit the row — it scrolls instead. */
  flex: 0 0 auto;

}

.project__info {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  min-width: 0;
  min-height: 0;
  /* Long Instagram captions scroll within the panel rather than stretching the
     dialog, which would otherwise squeeze the image. */
  overflow-y: auto;
  overscroll-behavior: contain;
}

.project__title {
  font-size: 1rem;
  font-weight: 500;
}

.project__tags,
.project__date {
  opacity: 0.55;
}

.project__caption {
  margin: 0.75rem 0 0;
  max-width: 46ch;
  font-size: 0.875rem;
  line-height: 1.55;
  opacity: 0.7;
  /* Instagram captions carry their own line breaks; keep them. */
  white-space: pre-wrap;
}

/* Smaller than the one on /info: it shares the metadata panel with the title,
   tags, date and caption, and on a narrow screen that panel is capped at 38% of
   the dialog. */
.project__close {
  --arrow-size: clamp(1.5rem, 4vw, 2rem);

  align-self: flex-start;
  order: -1;
  margin-bottom: 0.25rem;
}

/* Too narrow for two columns: stack, with the image given the room it needs and
   the metadata scrolling beneath it. */
@media (width < 48rem) {
  .project {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: minmax(0, 1fr) auto;
    /* The panel below takes up to 38% of the dialog, so leave the image the
       rest. Expressed against --proj-h, not dvh, so both shares account for the
       header strip the dialog no longer covers. */
    --proj-img-max-h: calc(0.62 * var(--proj-h) - var(--gutter));
  }

  .project__info {
    max-height: calc(0.38 * var(--proj-h));
  }
}

/* --------------------------------------------------------------------------
   Motion.

   Reveal is scoped under .js-reveal, a class set by a tiny inline script in
   the head. That ordering matters in both directions: setting it before first
   paint avoids a flash of visible content, and gating on it means content is
   never hidden unless something is definitely there to un-hide it. The inline
   script also arms a timeout that drops the class if main.js never runs, so a
   failed script can't leave the page blank below the fold.
   -------------------------------------------------------------------------- */

.js-reveal .reveal {
  opacity: 0;
  transform: translateY(8px);
  transition:
    opacity var(--reveal-dur) var(--ease),
    transform var(--reveal-dur) var(--ease);
  transition-delay: var(--reveal-delay, 0ms);
}

.js-reveal .reveal.is-visible {
  opacity: 1;
  transform: none;
}

/* Applied for a single frame while the reveal is re-armed after a shuffle, so
   resetting to the hidden state does not animate backwards. Three classes, so it
   outranks the transition on `.js-reveal .reveal` above. */
.js-reveal .reveal.reveal--reset {
  transition: none;
}

/* --------------------------------------------------------------------------
   Cross-document transitions, so moving between / and /info/ does not read as
   a page load. No JS: this is the CSS-only cross-document View Transitions
   API, and both documents opt in because they share this stylesheet.
   -------------------------------------------------------------------------- */

@view-transition {
  navigation: auto;
}

/* Naming the persistent chrome is what removes the "it reloaded" feeling. Named
   elements are snapshotted and matched between the two documents, so the header,
   footer and controls are held continuous while only the content behind them
   cross-fades (that content rides the `root` snapshot).

   Deliberately NOT naming <main>: a named element is snapshotted at its full
   ink area, and main is ~28000px tall on the archive — an enormous texture to
   composite. The root snapshot is only viewport-sized, so letting the content
   ride it is both cheaper and visually identical. */
.site-header {
  view-transition-name: site-header;
}

.site-footer {
  view-transition-name: site-footer;
}

.controls {
  view-transition-name: site-controls;
}

::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 200ms;
  animation-timing-function: var(--ease);
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    transition-delay: 0ms !important;
  }

  .js-reveal .reveal {
    opacity: 1;
    transform: none;
  }

  @view-transition {
    navigation: none;
  }
}

/* --------------------------------------------------------------------------
   404. A real page in the site's own voice rather than the host's default,
   which on cPanel is an unstyled Apache error naming the server version.
   -------------------------------------------------------------------------- */

.notfound {
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  align-items: start;
  padding: clamp(3rem, 12vh, 8rem) var(--gutter);
  max-width: 42rem;
}

.notfound p {
  margin: 0;
}

.notfound .mono {
  opacity: 0.5;
}

.notfound__link {
  text-decoration: underline;
  text-decoration-color: color-mix(in srgb, var(--fg) 30%, transparent);
  text-underline-offset: 3px;
  transition: text-decoration-color 120ms var(--ease);
}

.notfound__link:hover,
.notfound__link:focus-visible {
  text-decoration-color: currentColor;
}

/* --------------------------------------------------------------------------
   The /info portrait. Black and white already, so it does not break the rule
   that artwork is the only colour on the site.
   -------------------------------------------------------------------------- */

.portrait {
  margin: 0;
  /* Containing block for the credit, which sits over the photo. */
  position: relative;
}

/* Below 64rem the side column is gone and the figure would run the full content
   width — measured at 722px on a 768px viewport, more than twice its 320px
   desktop size, and the single widest render on the site's own /info page. A
   portrait does not need to be full-bleed on a tablet, and capping it here is
   what keeps the largest rendition at 1200px instead of 1600. */
@media (width < 64rem) {
  .portrait {
    max-width: 28rem;
  }
}

.portrait img {
  width: 100%;
  height: auto;
}

/* Credit sits INSIDE the photo, bottom right. Below it, it read as a caption
   belonging to the bio that follows rather than to the image above it.
   The plate is --bg at 78%, so it holds the text in either theme and over any
   part of the photograph; the text itself stays quiet at 0.7. */
.portrait__credit {
  position: absolute;
  right: 0.5rem;
  bottom: 0.5rem;
  margin: 0;
  padding: 0.25rem 0.5rem;
  background: color-mix(in srgb, var(--bg) 78%, transparent);
  opacity: 0.7;
}

.portrait__credit a {
  text-decoration: underline;
  text-decoration-color: color-mix(in srgb, var(--fg) 30%, transparent);
  text-underline-offset: 3px;
  transition: text-decoration-color 120ms var(--ease);
}

.portrait__credit a:hover,
.portrait__credit a:focus-visible {
  text-decoration-color: currentColor;
}

/* Step arrows for a multi-image stage. Created by main.js, so a project page
   without scripting still scrolls natively rather than showing dead controls.
   Same box as the floating controls, so the two read as one system. */
.stage-nav {
  position: absolute;
  /* Lines up with the floating controls. Those sit --gutter from the bottom of
     the viewport; the stage wrap's own bottom is already --content-top above it,
     so the difference is what puts the two rows on one line. */
  bottom: calc(var(--gutter) - var(--content-top));
  z-index: 2;
  display: grid;
  place-items: center;
  width: 2.25rem;
  height: 2.25rem;
  padding: 0;
  background: var(--bg);
  border: var(--rule);
  border-radius: 2px;
  color: var(--fg);
  cursor: pointer;
  opacity: 0.85;
  transition: opacity 120ms var(--ease);
}

.stage-nav:hover {
  opacity: 1;
}

/* Out of the way rather than gone: a disappearing control at the ends makes the
   remaining one jump. */
.stage-nav[disabled] {
  opacity: 0.25;
  cursor: default;
}

.stage-nav svg {
  fill: none;
  stroke: currentColor;
}

/* Paired at the bottom-right of the image area rather than pinned to the left
   and right edges of the viewport, where they read as browser chrome. */
.stage-nav--next {
  right: 0.75rem;
}

.stage-nav--prev {
  right: 3.5rem;
}

/* (a) Only on hover — they are an aid, not furniture. Gated on a real hover
   device: on touch there is no hover state, so hiding them there would leave no
   way to reach them at all. Focus still reveals them for keyboard use. */
@media (hover: hover) {
  .stage-nav,
  .stage-nav[disabled] {
    opacity: 0;
  }

  .project__stage-wrap:hover .stage-nav,
  .stage-nav:focus-visible {
    opacity: 0.85;
  }

  .project__stage-wrap:hover .stage-nav[disabled] {
    opacity: 0.25;
  }
}

/* --------------------------------------------------------------------------
   Phones. The project view stops being a viewport-height panel with two
   independent scrollers and becomes ordinary flow.
   -------------------------------------------------------------------------- */

@media (width < 48rem) {
  /* (d) ONE scroll container instead of three. The stage and the metadata panel
     each scrolled internally, so a poster stayed pinned while the page moved and
     a long caption hid behind it. Now the document scrolls — or the dialog does,
     which is the same thing from the reader's side. */
  .project {
    display: block;
    height: auto;
    max-height: none;
    /* (b) Equal space above and to the sides: it was --content-top over
       --gutter, which on a 390px screen is 24px against 16px. */
    padding: var(--gutter);
  }

  dialog.project {
    overflow-y: auto;
  }

  .project__stage {
    /* Horizontal only. Vertical is the document's job now. */
    overflow-y: visible;
    /* Top-aligned, not centred. Every project today is 4:5, so the two look
       identical — but the moment a set mixes aspect ratios, centring staggers
       the row and each image starts at a different height. Top edges give the
       row a single line to read from. */
    align-items: start;
  }

  .project__info {
    max-height: none;
    overflow: visible;
    margin-top: var(--gutter);
  }

  .project__img {
    /* No dvh ceiling: the page scrolls, so a poster can be its natural height
       and stay legible rather than being squeezed into a phone viewport. */
    max-height: none;
  }

  /* Step arrows are ALWAYS visible here. A touch screen has no hover state, so
     the hover-revealed treatment used on desktop would leave them unreachable.
     They were hidden outright at first; visible-always is the better answer,
     since the swipe affordance is otherwise invisible. */
  .stage-nav {
    display: grid;
    /* --gutter is smaller than --content-top on a phone, so the desktop offset
       calc goes negative here and would drop them below the image. */
    bottom: 0.75rem;
    opacity: 0.85;
  }

  .stage-nav[disabled] {
    opacity: 0.25;
  }

  /* No edge rule and no end padding on a phone. Both exist to say "the row
     continues" on a pointer device; with the arrows always on screen they are
     redundant, and the rule in particular read as a stray line beside the
     artwork. */
  .project__stage-wrap::after {
    content: none;
  }

  .project__stage:not(.project__stage--single) {
    padding-right: 0;
  }
}

/* Caption links. Plain underline in the sans: the serif belongs to the bio's one
   personal link, and pulling the face onto project pages for the occasional
   caption link is a request those pages should not pay for. */
.project__caption a {
  text-decoration: underline;
  text-decoration-color: color-mix(in srgb, var(--fg) 40%, transparent);
  text-underline-offset: 3px;
  transition: text-decoration-color 120ms var(--ease);
}

.project__caption a:hover,
.project__caption a:focus-visible {
  text-decoration-color: currentColor;
}
