@import url("https://fonts.googleapis.com/css2?family=EB+Garamond:ital,wght@0,400;0,500;1,400&family=DM+Mono:wght@300;400;500&display=swap");

/* keisot — an archive that degrades as you look at it.
   Two environments, not two themes: you look at the work in the dark
   (data-env absent) and read about it in the light (data-env="light").
   Never user-selectable. */

/* Every text tier clears WCAG AA (4.5:1) against its own ground — measured,
   not guessed. The quiet hierarchy this design wants therefore comes from
   size, case and tracking rather than from fading text toward the paper. */
:root {
  --paper: #0b0a09;
  --ink: #cfc9c0; /* 12.03:1 */
  --dim: #9a9186; /*  6.38:1 */
  --faint: #8b8378; /*  5.29:1 */
  --rule: #211e1b; /* border only, never text */

  --serif: "EB Garamond", Georgia, "Times New Roman", serif;
  --sans: "DM Mono", ui-monospace, SFMono-Regular, Menlo, monospace;

  --edge: clamp(18px, 4.2vw, 60px);
  --lift: cubic-bezier(0.22, 0.61, 0.36, 1);

  /* The same paper, as channels. Needed because a gradient must end at ZERO
     ALPHA OF THIS COLOUR — `transparent` is transparent BLACK, so a ramp from
     #0b0a09 to `transparent` slides off-hue on the way down and shows as a
     dirty band across the picture. This is the usual cause of "banding" on a
     dark ground and no amount of extra stops fixes it. */
  --paper-rgb: 11 10 9;

  /* The header sits ON the picture, not above it.
     There was a solid band here that the image started BELOW. It made the
     header a bar with its own height, so it took a bite out of every work
     instead of resting on one — Paul's "OVER the image, not ON TOP". The whole
     point of a full-bleed entrance is that the picture reaches all four edges
     and the mark floats on it.
     So there is no band. Just a short wash, only as tall as the wordmark needs
     to stay legible over whatever is behind it, and gone before it can read as
     a letterbox. The picture underneath is untouched and complete. */
  --fade: clamp(110px, calc(var(--edge) + 90px), 150px);
}

/* The reading ground is warm grey, not cream. A neutral grey would put the page
   and the work in the same material and the picture would stop being an object
   sitting on something; a cream drifts somewhere this practice is not. This is
   paper stock with almost all of the cream taken out. */
[data-env="light"] {
  --paper: #dedcd8;
  /* MUST move with --paper. This is the same colour in channels, for the top
     wash, which cannot read a hex. Leaving it behind in :root put a near-black
     gradient over a light page — a heavy bar across the top of the statement,
     because the wash was still painting in the dark palette's paper. If
     --paper ever changes again, this changes with it. */
  --paper-rgb: 222 220 216;
  --ink: #232322; /* 11.96:1 */
  --dim: #54524e; /*  5.83:1 */
  --faint: #5d5b57; /*  5.08:1 */
  --rule: #c6c4bf;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html {
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--serif);
  font-size: 18px;
  line-height: 1.55;
  overflow-x: hidden;
  transition: background 900ms var(--lift), color 900ms var(--lift);
}
/* keeps the footer on the floor of short pages instead of halfway up them */
main {
  flex: 1 0 auto;
}

/* Content dissolves into the top edge rather than sliding under the wordmark.
   This is the alternative to a sticky header bar: it keeps the mark legible
   over anything, and dissolving is already the language of the work. */
body::before {
  content: "";
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  height: var(--fade);
  z-index: 40;
  pointer-events: none;
  /* Never fully opaque, at any point: the picture must be visible under the
     mark, or this is a bar again by another name.
     The stops are measured FROM THE MARK, not as fractions of the height. Both
     the mark's inset and the wash scale with --edge, so proportional stops let
     the mark drift into the weak end as the window grows — at 1440 it sat at
     0.51 falling to 0.21, which is not enough behind light type over a bright
     photograph. Anchored like this it is 0.90 to 0.72 behind the wordmark at
     every width, and clear of the picture within another 50px.
     Eased over several stops rather than a straight line, and ending on
     zero-alpha PAPER, never `transparent`: `transparent` is transparent BLACK,
     so a ramp from #0b0a09 to it slides off-hue and shows as a dirty edge. */
  /* ONE smooth curve, top to bottom. Every visible "line" in this wash has been
     a CORNER — a place where the slope changed abruptly and the eye read the
     kink as an edge drawn across the picture. First at the end, where a
     constant ramp hit zero and stopped. Then, after that was eased, just under
     the wordmark, where a near-flat hold met a steep drop and the slope jumped
     2.8x in one stop. Fixing corners one at a time only moves the line.
     So the alpha is a single smootherstep, 0.60 * (1 - 3t^2 + 2t^3), sampled at
     even intervals. Its slope is zero at BOTH ends and changes continuously in
     between, so there is no kink anywhere for a band to form on. The numbers
     below are that function, not chosen by eye — do not hand-tune one of them
     without recomputing the rest, or the corner comes back wherever you did.
     The peak is 0.60, not 0.94: the wordmark no longer depends on this for
     legibility (see .mark's shadow), so the wash only has to soften the top
     edge rather than carry type, and it can be much lighter for it. */
  background: linear-gradient(to bottom,
    rgb(var(--paper-rgb) / .600) 0,
    rgb(var(--paper-rgb) / .589) calc(var(--fade) * .08),
    rgb(var(--paper-rgb) / .559) calc(var(--fade) * .16),
    rgb(var(--paper-rgb) / .513) calc(var(--fade) * .24),
    rgb(var(--paper-rgb) / .455) calc(var(--fade) * .32),
    rgb(var(--paper-rgb) / .389) calc(var(--fade) * .40),
    rgb(var(--paper-rgb) / .318) calc(var(--fade) * .48),
    rgb(var(--paper-rgb) / .246) calc(var(--fade) * .56),
    rgb(var(--paper-rgb) / .177) calc(var(--fade) * .64),
    rgb(var(--paper-rgb) / .115) calc(var(--fade) * .72),
    rgb(var(--paper-rgb) / .062) calc(var(--fade) * .80),
    rgb(var(--paper-rgb) / .024) calc(var(--fade) * .88),
    rgb(var(--paper-rgb) / .006) calc(var(--fade) * .94),
    rgb(var(--paper-rgb) / 0)    var(--fade));
}

/* The paper grain. Fixed, so it belongs to the surface and not to the
   scrolling content — printed matter, not a texture that slides. */
body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 60;
  pointer-events: none;
  opacity: 0.5;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23n)' opacity='0.36'/%3E%3C/svg%3E");
}

img {
  max-width: 100%;
  height: auto;
  display: block;
}

a {
  color: inherit;
  text-decoration: none;
}

:focus-visible {
  outline: 1px solid var(--ink);
  outline-offset: 4px;
}

.skip {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 100;
  background: var(--paper);
  color: var(--ink);
  padding: 12px 18px;
  font-family: var(--sans);
  font-size: 13px;
}
.skip:focus {
  left: var(--edge);
  top: var(--edge);
}

/* ---------------------------------------------------------------- meta type */

.meta,
.mark,
.idx-n,
.onward,
.acq {
  font-family: var(--sans);
  font-size: 13px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  font-weight: 400;
}

.meta {
  color: var(--dim);
}

/* -------------------------------------------------------------- the wordmark
   The only permanent element on the site, and the only way into the contents.
   No blinking underscore: that cursor is OKEN's own mark, not a client's. */

/* NO transform on the mark. The wordmark is already the mirrored CHARACTERS —
   ʞɘiƨot, the same string the captions are written in — so flipping it here
   would turn it back the right way round. glitch.js still splits it per letter,
   but only so a single glyph can be sliced now and then. */
.mark .mk {
  display: inline-block;
}
.mark {
  position: fixed;
  top: var(--edge);
  left: var(--edge);
  z-index: 50;
  font-family: var(--serif);
  font-size: 18px;
  letter-spacing: 0.34em;
  text-transform: none;
  color: var(--ink);
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  line-height: 1;
  /* The mark carries its own ground. It used to rely on the wash at the top of
     the page being dark enough to sit on, which is what forced that wash to be
     heavy — and a heavy wash is a band across the picture however carefully it
     is eased. A soft halo in the page's own paper does the same job in the
     shape of the letters instead of in the shape of a bar, so the wash is free
     to be almost nothing. Paper, not black, so it inverts with the palette and
     backs dark type on the light pages just as well. */
  text-shadow:
    0 0 2px  rgb(var(--paper-rgb) / .55),
    0 0 10px rgb(var(--paper-rgb) / .75),
    0 0 26px rgb(var(--paper-rgb) / .55);
}
/* The caret. OKEN's own mark is a clean terminal blink on a metronome — this
   one must not be that, and is not: glitch.js holds it lit for seconds at a
   time, drops it for an instant, and now and then stutters. It is a cursor on a
   failing line, not a healthy one, and it never repeats a pattern.
   With no script, or under reduced motion, it is simply a static underscore —
   which is what this site shipped with and what it falls back to. */
.mark::after {
  content: "_";
  color: var(--dim);
}
[data-cur="0"] .mark::after {
  opacity: 0;
}
[data-cur="dim"] .mark::after {
  opacity: 0.35;
}

/* The pointer, at the door. The same caret as the one after the name, on the
   same clock — glitch.js builds it, and only where a real pointer exists to be
   replaced. `data-kc` is set by that script, so a page whose script never ran
   keeps its ordinary cursor instead of losing it to a mark that never appears. */
.mouth[data-kc] {
  cursor: none;
}
.kc {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 60;
  pointer-events: none;
  font-family: var(--serif);
  font-size: 26px;
  line-height: 1;
  margin: -22px 0 0 -7px;
  color: var(--ink);
  opacity: 0;
}
.kc[data-on="1"] {
  opacity: 1;
}
[data-cur="0"] .kc[data-on="1"] {
  opacity: 0;
}
[data-cur="dim"] .kc[data-on="1"] {
  opacity: 0.35;
}
/* nothing follows the pointer once the frame is being taken */
body:has(.mouth[data-diving]) .kc {
  opacity: 0 !important;
}

/* ------------------------------------------------------------- the contents
   A book's table of contents, not a menu. Opens from the wordmark,
   closes on the wordmark, Escape, or "/". */

/* Without JS this is an ordinary contents list at the foot of the document —
   reachable, readable, and linked from the wordmark. Scripting only promotes
   it to an overlay. Navigation is never hidden behind a script that failed. */
.contents {
  padding: calc(var(--edge) * 3) var(--edge) calc(var(--edge) * 2);
  border-bottom: 1px solid var(--rule);
}
[data-js] .contents {
  position: fixed;
  inset: 0;
  z-index: 45;
  background: var(--paper);
  display: grid;
  align-content: center;
  border-bottom: 0;
  padding: calc(var(--edge) * 2) var(--edge);
  opacity: 0;
  visibility: hidden;
  transition: opacity 520ms var(--lift), visibility 520ms;
}
[data-js] .contents[data-open="1"] {
  opacity: 1;
  visibility: visible;
}
.contents ol {
  list-style: none;
  margin: 0 auto;
  padding: 0;
  width: min(560px, 100%);
}
.contents li {
  border-bottom: 1px solid var(--rule);
}
[data-js] .contents li {
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 460ms var(--lift), transform 460ms var(--lift);
}
[data-js] .contents[data-open="1"] li {
  opacity: 1;
  transform: none;
}
.contents li:nth-child(1) { transition-delay: 60ms; }
.contents li:nth-child(2) { transition-delay: 120ms; }
.contents li:nth-child(3) { transition-delay: 180ms; }
.contents li:nth-child(4) { transition-delay: 240ms; }
.contents li:nth-child(5) { transition-delay: 300ms; }
.contents a {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 20px;
  padding: 16px 0;
  font-size: 26px;
  color: var(--dim);
  transition: color 300ms var(--lift);
}
.contents a:hover,
.contents a:focus-visible {
  color: var(--ink);
}
.contents a span {
  font-family: var(--sans);
  font-size: 13px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--faint);
}

/* ----------------------------------------------------------------- the plate
   An image is a plate tipped into a book: off-axis, generous margin,
   caption small and beside it. Never edge to edge — the originals are
   900px and enlarging them would trade real grain for interpolation. */

.plate {
  position: relative;
  margin: 0;
}
.plate img {
  width: 100%;
}
/* The develop-from-blur is gated on scripting. Without this the artwork starts
   at opacity 0 and only JS ever reveals it — so with JS off every page was a
   blank sheet. Content is never the reward for a script that ran. */
[data-js] .plate img {
  filter: blur(22px);
  opacity: 0;
  transition: filter 1900ms var(--lift), opacity 1400ms var(--lift);
}
[data-js] .plate.is-in img {
  filter: blur(0);
  opacity: 1;
}
.plate figcaption {
  margin-top: 14px;
  display: flex;
  justify-content: space-between;
  gap: 16px;
}
.plate .t {
  font-size: 19px;
  font-style: italic;
  color: var(--ink);
}

/* The misregistered band: a second copy of the same file, clipped to a
   horizontal strip and pushed sideways. Built in JS, so with scripting off
   the artwork is simply whole. */
.slip {
  position: absolute;
  inset: 0;
  pointer-events: none;
  clip-path: inset(var(--slip-top, 40%) 0 var(--slip-bot, 52%) 0);
  transform: translateX(var(--slip-x, 7px));
  opacity: 0.92;
}

/* Degradation states, applied only to work the visitor has already seen.
   Never enough to be certain of. */
/* target the canvas as well as the source image — once glitch.js runs, the
   <img> is hidden and a rule aimed only at it would degrade nothing */
[data-seen="1"] .plate img, [data-seen="1"] .plate .cv { filter: contrast(1.06) brightness(0.94); }
[data-seen="2"] .plate img, [data-seen="2"] .plate .cv { transform: scale(1.045); }
[data-seen="3"] .plate img, [data-seen="3"] .plate .cv { filter: brightness(0.88) blur(0.4px); }
[data-seen] .plate { overflow: hidden; }

/* ------------------------------------------------------------------ entrance */

/* Deliberately off-axis: the plate sits right of centre, tipped into the page,
   with the fragment held in the wide left margin. Centring it would read as a
   lightbox — a picture being displayed rather than a picture on a page. */
.entrance {
  min-height: 100svh;
  display: grid;
  grid-template-columns: 1.2fr minmax(0, 33rem) 1fr;
  align-items: center;
  padding: calc(var(--edge) * 2.4) var(--edge) var(--edge);
  gap: var(--edge);
}
.entrance .plate {
  grid-column: 2;
}
.entrance .line {
  grid-column: 1 / -1;
  justify-self: start;
  align-self: end;
  max-width: 22em;
  font-size: 20px;
  font-style: italic;
  color: var(--dim);
  margin: 0;
}
/* Stop moving and it resolves a little further. Slow enough to doubt. */
.entrance.still .plate img {
  filter: contrast(1.09) brightness(0.9);
  transition: filter 2600ms var(--lift);
}

/* class is .onward, not .enter — "enter" in a class attribute would count as a
   second occurrence of the link's own label and stop it ever being editable */
.onward {
  position: fixed;
  right: var(--edge);
  bottom: var(--edge);
  z-index: 50;
  color: var(--dim);
  border-bottom: 1px solid var(--faint);
  padding-bottom: 3px;
  min-height: 44px;
  display: inline-flex;
  align-items: center;
  transition: color 300ms var(--lift), border-color 300ms var(--lift);
}
/* nothing from the page beneath should float over the contents layer */
.nav-open .onward,
.nav-open .foot {
  opacity: 0;
  pointer-events: none;
}
.onward:hover,
.onward:focus-visible {
  color: var(--ink);
  border-color: var(--ink);
}

/* -------------------------------------------------------------------- pages */

/* The top 150px of every page is under a fixed paper-to-transparent veil, so
   the wordmark stays legible over anything scrolling beneath it. On a narrow
   screen --edge collapses and this page's first label landed INSIDE that veil,
   which washed it out and read as a colour bug rather than as a fade. The
   floor keeps the first line clear of it at every width. */
.page {
  padding: max(calc(var(--edge) * 3.2), 172px) var(--edge) calc(var(--edge) * 2);
  max-width: 100%;
}
/* h1 defaults to 700 and only 400/500 are loaded, so without this the browser
   synthesises a bold and the page reads as a headline rather than a note. */
.lede {
  font-size: clamp(20px, 2.2vw, 25px);
  font-weight: 400;
  line-height: 1.42;
  max-width: 22em;
  margin: 0 0 2.4em;
  color: var(--ink);
}

/* Reading pages sit in a book measure with their section labels out in the
   left margin, the way a catalogue annotates its own text. */
.read {
  max-width: 58rem;
  margin-inline: auto;
}
.prose {
  display: grid;
  grid-template-columns: 9rem minmax(0, 32em);
  gap: 0 2.6rem;
  color: var(--dim);
}
.prose p {
  grid-column: 2;
  margin: 0 0 1.35em;
}
/* The page's own label sits in the margin with the others rather than over the
   text as a title. Same voice as `prints` and `enquiries` — the page is an
   annotated document, not a headline and a body. */
.prose h2,
.prose .tag {
  grid-column: 1;
  /* The contents overlay sets its labels in the serif; these are the same kind
     of label, so they are the same face — set in caps and tracked out, which is
     how a Garamond behaves as a small label rather than as running text. A cap
     height is shorter than the sans it replaced, so the size goes up to hold
     the same weight in the margin. */
  font-family: var(--serif);
  font-size: 15px;
  letter-spacing: 0.19em;
  text-transform: uppercase;
  font-weight: 500;
  color: var(--faint);
  margin: 0.3em 0 1.1em;
}

/* The line the page is named for, kept until last. Same size AND same colour as
   the sentences above it — it earns its weight by ARRIVING last, after
   everything else has landed. Setting it in the darker ink made it read as a
   different kind of text sitting in the middle of the statement rather than as
   its final line. Air on both sides is the whole distinction. */
.prose .said {
  grid-column: 2;
  margin: 2.4em 0 3.2em;
}

/* --------------------------------------------------------- works — sequence */

.seq {
  display: grid;
  gap: clamp(120px, 22vh, 260px);
  padding: calc(var(--edge) * 3.2) var(--edge) calc(var(--edge) * 4);
  justify-items: center;
}
/* a heading block already supplies the space above the first plate */
.page + .seq {
  padding-top: 0;
}
.seq .plate {
  width: min(30rem, 100%);
}
.seq .plate:nth-child(3n + 2) {
  justify-self: end;
  width: min(24rem, 100%);
}
.seq .plate:nth-child(3n) {
  justify-self: start;
  width: min(27rem, 100%);
}

/* ------------------------------------------------------------ archive index */

.idx {
  list-style: none;
  margin: 0;
  padding: 0;
  max-width: 46em;
}
.idx li {
  border-bottom: 1px solid var(--rule);
}
.idx a {
  display: grid;
  grid-template-columns: 4.5em 1fr auto;
  gap: 1.2em;
  align-items: baseline;
  padding: 11px 0;
  color: var(--dim);
  transition: color 260ms var(--lift);
}
.idx a:hover,
.idx a:focus-visible {
  color: var(--ink);
}
.idx-n {
  font-size: 13px;
  color: var(--faint);
}
.idx-t {
  font-size: 20px;
}
/* The library-card mark: you have been here, but not what changed. Drawn in
   CSS so the word is not written into the document one hundred times. */
.idx-s {
  font-family: var(--sans);
  font-size: 13px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--faint);
  opacity: 0;
}
[data-visited] .idx-s {
  opacity: 1;
}
[data-visited] .idx-s::after {
  content: "seen";
}

/* The reveal sits in the opposite margin, out of the reading column. */
.reveal {
  position: fixed;
  top: 50%;
  right: var(--edge);
  transform: translateY(-50%);
  width: min(20rem, 34vw);
  z-index: 20;
  pointer-events: none;
  opacity: 0;
  transition: opacity 420ms var(--lift);
}
.reveal[data-on="1"] {
  opacity: 1;
}
.reveal img {
  width: 100%;
}

/* -------------------------------------------------------------- artwork page */

.work {
  display: grid;
  grid-template-columns: minmax(0, 34rem) minmax(0, 16rem);
  gap: clamp(28px, 5vw, 76px);
  align-items: end;
  padding: calc(var(--edge) * 3.2) var(--edge) calc(var(--edge) * 2);
  justify-content: center;
}
.work h1 {
  font-size: clamp(26px, 3.4vw, 38px);
  font-weight: 400;
  font-style: italic;
  line-height: 1.15;
  margin: 0 0 1.1em;
}
.spec {
  margin: 0 0 2.4em;
}
.spec dt {
  font-family: var(--sans);
  font-size: 13px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--faint);
  margin-top: 1.3em;
}
.spec dd {
  margin: 0.2em 0 0;
  color: var(--dim);
}
.acq {
  display: inline-block;
  color: var(--ink);
  border-bottom: 1px solid var(--ink);
  padding-bottom: 4px;
  cursor: pointer;
  background: none;
  border-left: 0;
  border-right: 0;
  border-top: 0;
  min-height: 44px;
}
.pager {
  display: flex;
  justify-content: space-between;
  gap: 20px;
  padding: 0 var(--edge) calc(var(--edge) * 2);
  max-width: 64rem;
  margin: 0 auto;
}
.pager a {
  color: var(--dim);
  padding: 12px 0;
  min-height: 44px;
  display: inline-flex;
  align-items: center;
}
.pager a:hover,
.pager a:focus-visible {
  color: var(--ink);
}

/* ------------------------------------------------------------------- footer */

/* A colophon, not a section. It wrapped to two lines on a phone and took up as
   much of the screen as a work did; now it holds one line at any width and sits
   at the size of a footnote. `nowrap` with a shrinking gap rather than smaller
   type alone — the two halves have to stay on the same baseline. */
.foot {
  display: flex;
  flex-wrap: nowrap;
  gap: 0 clamp(12px, 4vw, 28px);
  justify-content: space-between;
  align-items: baseline;
  padding: calc(var(--edge) * 1.4) var(--edge);
  border-top: 1px solid var(--rule);
  color: var(--faint);
  /* Small enough to hold one line on the narrowest phone. `nowrap` alone was
     the bug: it kept the line but let the content push the page wider than the
     screen, so "write to the gallery" walked off the right edge and the whole
     document gained a horizontal scrollbar. The size now shrinks with the
     viewport instead, and min-width:0 lets a flex child actually give ground
     rather than refusing below its content width. */
  font-size: clamp(8px, 2.3vw, 10px);
  letter-spacing: 0.04em;
  overflow: hidden;
}
.foot > * {
  font-size: inherit;
  letter-spacing: inherit;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ------------------------------------------------------------------- mobile */

@media (max-width: 860px) {
  .entrance {
    grid-template-columns: 1fr;
    padding-top: calc(var(--edge) * 4);
  }
  .entrance .plate {
    grid-column: 1;
  }
  /* keep the fragment clear of the fixed "enter" in the opposite corner */
  .entrance .line {
    max-width: calc(100% - 7rem);
  }
  .work {
    grid-template-columns: 1fr;
    align-items: start;
  }
  .reveal {
    display: none;
  }
  .idx a {
    grid-template-columns: 3.6em 1fr auto;
  }
  .prose {
    grid-template-columns: 1fr;
    gap: 0;
  }
  /* One column here, so EVERYTHING comes back to it. Anything left sitting in
     column 2 gets placed on an implicit second track and floats off beside the
     text — which is exactly what the closing line did. */
  .prose p,
  .prose h2,
  .prose .tag,
  .prose .said {
    grid-column: 1;
  }
  .prose h2 {
    margin-top: 2.4em;
  }
  .prose .said {
    margin: 1.6em 0 2.2em;
  }
  .seq .plate,
  .seq .plate:nth-child(3n + 2),
  .seq .plate:nth-child(3n) {
    width: 100%;
    justify-self: center;
  }
}

/* --------------------------------------------------------- reduced motion
   Everything arrives in its final state at once. Nothing is hidden and
   nothing drifts — the quiet version, not a degraded one. */

@media (prefers-reduced-motion: reduce) {
  * {
    transition-duration: 1ms !important;
    animation-duration: 1ms !important;
  }
  .plate img {
    filter: none;
    opacity: 1;
  }
  .slip {
    display: none;
  }
}

/* ===================================================================
   THE WORK DOES NOT HOLD STILL
   glitch.js swaps each <img> for a canvas it can tear. The <img> stays
   in the DOM as the real element and the source pixels; hiding it is
   the LAST thing that happens, so a failure leaves the photograph.
   =================================================================== */

.cv {
  display: block;
  width: 100%;
  height: auto;
}
.cv-src {
  display: none;
}

/* Present to anything reading the page, absent to anything looking at it.
   `display:none` would hide it from screen readers too, which is the whole
   point of it existing — copy set in the mirror still has to say what it says
   to a search engine and to someone listening. */
.vh {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  white-space: nowrap;
  clip-path: inset(50%);
  border: 0;
}

/* ===================================================================
   THE DOCUMENT COMES APART
   The same failure the hero runs on the entrance, at the scale of a
   whole page: hard horizontal slabs dragged sideways (the SVG filter
   glitch.js builds), bands gone to ground, blocks put down in the
   wrong place, thin scan damage (the .mosh overlay). Every state here
   is scripted — a page that never runs them simply sits still.
   =================================================================== */

[data-moshing] main,
[data-moshing] .foot {
  filter: url(#fx-mosh);
}
/* The wordmark and the hamburger are part of the document, so they fail
   with it — but they are small and fixed, and a filter region measured off
   their own box would clip the drag. They lose their footing instead, and
   the bars fall over them like everything else. */
[data-moshing] .mark,
[data-moshing] .burger {
  opacity: 0.45;
  transition: opacity 260ms linear;
}
[data-leaving] main,
[data-leaving] .foot {
  opacity: 0.55;
  transition: opacity 420ms linear;
}
[data-fx]:not([data-arrived]) main {
  opacity: 0;
}
/* Fast, because on arrival the slabs are the entrance — a long fade would
   turn a document torn in out of order into a document dissolving in. */
[data-arrived] main {
  opacity: 1;
  transition: opacity 220ms linear;
}
.fx-defs {
  position: absolute;
  width: 0;
  height: 0;
  pointer-events: none;
}

.mosh {
  position: fixed;
  inset: 0;
  z-index: 200;
  pointer-events: none;
  display: none;
  overflow: hidden;
}
[data-moshing] .mosh {
  display: block;
}
.mosh span {
  position: absolute;
  background-color: rgb(6 5 4 / 92%);
}
.mosh span[data-paper] {
  background-color: var(--paper);
}
/* a band gone to ground: edge to edge, always */
.mosh .b {
  left: 0;
  right: 0;
}
/* scan damage: thin, sparse, never a full field of static */
.mosh .s {
  left: 0;
  right: 0;
  background-color: rgb(0 0 0 / 55%);
}
.mosh-veil {
  position: absolute;
  inset: 0;
  background: var(--paper);
  opacity: 0;
}

/* ===================================================================
   REVERSED TEXT
   The mirror is the practice's own signature, not an effect applied to
   it. The DOM text is never reversed — only the glyphs are — so screen
   readers, search and copy-paste all read plain text, and any mirrored
   string resolves the moment it is hovered or focused.
   =================================================================== */

/* NO transition. A title does not turn round over half a second — that is a
   card flipping over, and the smooth symmetrical arc is exactly what makes it
   read as a slide effect rather than as damage. The mirrored and the true state
   are both here so hovering works with scripting off; glitch.js slices across
   the instant it changes, which is what carries the change. */
.rev {
  display: inline-block;
  transform: scaleX(-1);
}
a:hover .rev,
a:focus-visible .rev,
.rev:hover {
  transform: scaleX(1);
}
/* A word that can be sliced. glitch.js wraps each word of a heading, and of
   the prose on the reading page, in one of these so it can be dragged sideways
   and put through the same slicer as the document itself.

   NOTHING here transitions. A word does not turn round over 600ms — that is a
   card flipping, which is a different and much older idea. It arrives wrong,
   judders, and snaps. Every step is a discrete style set from a timer. */
.gw {
  display: inline-block;
}

/* Never reverse anything a visitor needs in order to act. */
.acq .rev,
.foot .rev,
.contents .rev {
  transform: none;
}

/* ===================================================================
   THE ENTRANCE — type laid over and through the work
   Echoes are generated by app.js from the ONE real string in the
   document, so the phrase stays a single editable field.
   =================================================================== */

.entrance {
  position: relative;
}
.echo {
  position: absolute;
  inset: 0;
  z-index: 3;
  pointer-events: none;
  overflow: hidden;
}
.echo span {
  position: absolute;
  font-family: var(--sans);
  text-transform: uppercase;
  letter-spacing: 0.1em;
  font-size: 13px;
  color: var(--ink);
  white-space: nowrap;
}
.entrance .plate {
  position: relative;
  z-index: 2;
}
.entrance .line {
  position: relative;
  z-index: 4;
}

@media (prefers-reduced-motion: reduce) {
  .echo {
    display: none;
  }
  [data-moshing] main,
  [data-moshing] .foot,
  [data-leaving] main,
  [data-leaving] .foot {
    filter: none;
    opacity: 1;
  }
  [data-fx]:not([data-arrived]) main {
    opacity: 1;
  }
  .mosh {
    display: none;
  }
}

/* ===================================================================
   THE MOUTH — the entrance
   The featured works hang in a ring around a void. The ring is the
   navigation; the void is what you fall through. Neither the canvas
   nor the dive exists without scripting: .ring-src is a real list of
   real links and stays visible until hero.js is actually drawing.
   =================================================================== */

.mouth {
  position: relative;
  min-height: 100svh;
  width: 100%;
}
.mouth-cv {
  position: fixed;
  inset: 0;
  display: block;
  width: 100vw;
  height: 100svh;
}

/* the no-script state: a plain index of the featured works */
.ring-src {
  list-style: none;
  margin: 0;
  padding: calc(var(--edge) * 4) var(--edge) var(--edge);
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 18px;
}
.mouth[data-live] .ring-src {
  display: none;
}

/* On the entrance the contents is not summoned — it sits at dead centre,
   inside the ring, at the size of a caption. The works are the interface;
   the navigation is the smallest thing on the page. */
[data-page="entrance"] .mark {
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  top: auto;
  bottom: calc(var(--edge) * 2.6);
  z-index: 47;
}
[data-js] [data-page="entrance"] .contents {
  position: fixed;
  inset: auto;
  top: 50%;
  left: 50%;
  transform: translate(-50%, 0);
  top: auto;
  bottom: var(--edge);
  z-index: 47;
  background: none;
  padding: 0;
  opacity: 1;
  visibility: visible;
  display: block;
}
[data-page="entrance"] .contents ol {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 4px 20px;
  width: auto;
}
[data-page="entrance"] .contents li {
  border: 0;
  opacity: 1;
  transform: none;
}
[data-page="entrance"] .contents a {
  display: block;
  padding: 8px 0;
  font-family: var(--sans);
  font-size: 13px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
[data-page="entrance"] .contents a span {
  display: none;
}

/* the written way in, and the phrase, sit at the far corners */
[data-page="entrance"] .line {
  position: fixed;
  left: var(--edge);
  bottom: var(--edge);
  z-index: 47;
  margin: 0;
  max-width: calc(100% - 9rem);
  font-size: 19px;
  font-style: italic;
  color: var(--dim);
}
[data-page="entrance"] .foot {
  display: none;
}

/* falling: the interface lets go before the works do */
[data-diving] ~ .line,
[data-page="entrance"] .mouth[data-diving] {
  pointer-events: none;
}
[data-page="entrance"] .mouth[data-diving] ~ .line,
body:has(.mouth[data-diving]) .mark,
/* The hamburger was the one thing left standing while the whole interface let
   go — every other control faded and it just sat there, which read as a bug
   rather than as restraint. */
body:has(.mouth[data-diving]) .burger,
body:has(.mouth[data-diving]) .contents,
body:has(.mouth[data-diving]) .onward {
  opacity: 0;
  transition: opacity 700ms var(--lift);
}

@media (max-width: 860px) {
  [data-page="entrance"] .line {
    font-size: 16px;
    max-width: calc(100% - 7rem);
  }
}

@media (prefers-reduced-motion: reduce) {
  .mouth-cv {
    position: relative;
  }
}

/* ===================================================================
   THE ENTRANCE ON A PHONE
   Desktop parks four separate fixed elements around the edges — the
   wordmark and nav at centre, the phrase bottom-left, "enter" bottom-
   right. On a narrow screen they all land in the same place and pile
   up on each other. Here they are given their own bands instead, and
   the wordmark goes back to the corner it occupies on every other page.
   =================================================================== */

@media (max-width: 860px) {
  [data-page="entrance"] .mark {
    top: var(--edge);
    left: var(--edge);
    bottom: auto;
    transform: none;
  }

  /* nav sits on the floor, left, clear of "enter" in the other corner */
  [data-js] [data-page="entrance"] .contents {
    top: auto;
    left: var(--edge);
    right: 6.5rem;
    bottom: var(--edge);
    transform: none;
  }
  [data-page="entrance"] .contents ol {
    display: grid;
    grid-template-columns: repeat(2, auto);
    justify-content: start;
    justify-items: start;
    gap: 2px 18px;
    width: auto;
  }
  [data-page="entrance"] .contents a {
    padding: 6px 0;
    font-size: 12px;
    letter-spacing: 0.12em;
  }

  /* the phrase gets its own band above the nav */
  [data-page="entrance"] .line {
    left: var(--edge);
    right: var(--edge);
    bottom: calc(var(--edge) + 74px);
    max-width: none;
    font-size: 16px;
  }

  [data-page="entrance"] .onward {
    bottom: var(--edge);
    right: var(--edge);
  }
}

/* Very narrow. NOT one column: stacking four items made the nav 122px tall,
   which pushed it straight through the phrase above it. Two columns still fit
   at 320px once the labels are set small. */
@media (max-width: 430px) {
  [data-js] [data-page="entrance"] .contents {
    right: 5.2rem;
  }
  [data-page="entrance"] .contents a {
    font-size: 11px;
    letter-spacing: 0.1em;
  }
  [data-page="entrance"] .line {
    font-size: 15px;
  }
}

/* ===================================================================
   TAP TARGETS
   The wordmark is the only way into the contents on every inner page,
   and at 18px tall it was an 18px target. Padding gives it a real hit
   area; the negative offset keeps the mark optically where it was.
   =================================================================== */

.mark {
  padding: 13px 16px 13px 0;
  margin: -13px 0 0 0;
}
[data-page="entrance"] .mark {
  margin: -13px 0 0 0;
}
.foot a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
}

/* The entrance nav is the primary navigation on a phone, so its links need a
   real 44px target. That makes the two rows ~90px tall, so the phrase above
   has to move up to clear them. */
@media (max-width: 860px) {
  [data-page="entrance"] .contents a {
    padding: 14px 0;
  }
  [data-page="entrance"] .line {
    bottom: calc(var(--edge) + 104px);
  }
}

/* ===================================================================
   REVISION — smaller type, hamburger, no "enter", tiny footer
   =================================================================== */

/* Everything down a step except the wordmark, which was already right. */
body { font-size: 17px; }
.meta, .idx-n, .acq { font-size: 12px; }
.lede { font-size: clamp(17px, 1.9vw, 21px); }
.idx-t { font-size: 17px; }
.plate .t { font-size: 16px; }
.work h1 { font-size: clamp(21px, 2.6vw, 28px); }
.prose { font-size: 16px; }
.prose h2 { font-size: 12px; }
.contents a { font-size: 21px; }
.contents a span { font-size: 11px; }

/* The footer is a colophon, not a section. */
.foot {
  padding: calc(var(--edge) * 0.9) var(--edge);
  font-size: 11px;
  font-family: var(--sans);
  letter-spacing: 0.05em;
  text-transform: uppercase;
}
.foot a { min-height: 34px; }

/* ------------------------------------------------------------ hamburger
   One trigger for the contents on every page, entrance included. The
   wordmark goes back to being a wordmark: it links home and nothing else. */

.burger {
  position: fixed;
  top: var(--edge);
  right: var(--edge);
  z-index: 50;
  width: 44px;
  height: 44px;
  padding: 0;
  margin: -11px -11px 0 0;
  display: grid;
  align-content: center;
  justify-items: end;
  gap: 5px;
  background: none;
  border: 0;
  cursor: pointer;
}
.burger span {
  display: block;
  width: 22px;
  height: 1px;
  background: var(--ink);
  /* Same reasoning as .mark: a 1px rule over a photograph needs a ground, and
     it should be the rule's own, not a bar across the top of the work. */
  box-shadow:
    0 0 3px  rgb(var(--paper-rgb) / .60),
    0 0 12px rgb(var(--paper-rgb) / .70);
  transition: transform 380ms var(--lift), opacity 260ms var(--lift), width 380ms var(--lift);
}
.burger span:nth-child(2) { width: 15px; }
.nav-open .burger span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
.nav-open .burger span:nth-child(2) { opacity: 0; }
.nav-open .burger span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); width: 22px; }

/* The entrance now uses the same hamburger as everywhere else, so the
   centred link row and the "enter" affordance both go. */
[data-page="entrance"] .mark,
[data-js] [data-page="entrance"] .mark {
  top: var(--edge);
  left: var(--edge);
  bottom: auto;
  transform: none;
}
[data-js] [data-page="entrance"] .contents {
  position: fixed;
  inset: 0;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  transform: none;
  background: var(--paper);
  display: grid;
  align-content: center;
  padding: calc(var(--edge) * 2) var(--edge);
  opacity: 0;
  visibility: hidden;
}
[data-js] [data-page="entrance"] .contents[data-open="1"] {
  opacity: 1;
  visibility: visible;
}
[data-page="entrance"] .contents ol {
  display: block;
  width: min(560px, 100%);
  margin: 0 auto;
}
[data-page="entrance"] .contents li { border-bottom: 1px solid var(--rule); }
[data-page="entrance"] .contents a {
  display: flex;
  justify-content: space-between;
  padding: 15px 0;
  font-family: var(--serif);
  font-size: 21px;
  letter-spacing: 0.02em;
  text-transform: none;
}
[data-page="entrance"] .contents a span { display: block; }
[data-page="entrance"] .line {
  left: var(--edge);
  right: var(--edge);
  bottom: var(--edge);
  max-width: 26em;
  font-size: 16px;
}

/* ------------------------------------------------- the sequence, full bleed
   The inset plate with a heading over it read as a card in a page. These are
   works: they go edge to edge and the caption sits quietly under each one. */

.seq--full {
  display: block;
  padding: 0 0 calc(var(--edge) * 3);
  gap: 0;
}
.seq--full .plate {
  width: 100%;
  margin: 0 0 clamp(56px, 11vh, 130px);
}
.seq--full .plate:last-child { margin-bottom: 0; }
.seq--full .plate img,
.seq--full .plate .cv { width: 100%; }
.seq--full .plate figcaption {
  padding: 12px var(--edge) 0;
  margin: 0;
}

/* a fragment standing alone between works — a quiet interval, not a heading */
.beat {
  margin: clamp(60px, 14vh, 150px) 0;
  padding: 0 var(--edge);
  font-family: var(--sans);
  font-size: 12px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--dim);
  text-align: center;
}

/* --- menu layering and list geometry -------------------------------------
   The wordmark and the contents overlay were both z-index 47, so the overlay
   (later in the DOM) painted straight over the mark and it vanished the moment
   the menu opened. And the entrance list was still inheriting the two-column
   grid from the old centred-nav layout, so every rule stopped half way. */

.mark { z-index: 52; }
.burger { z-index: 52; }
[data-page="entrance"] .mark { z-index: 52; }
[data-js] .contents,
[data-js] [data-page="entrance"] .contents { z-index: 46; }

[data-page="entrance"] .contents ol,
.contents ol {
  display: block;
  grid-template-columns: none;
  width: min(560px, 100%);
  margin: 0 auto;
}
[data-page="entrance"] .contents li,
.contents li {
  display: block;
  width: auto;
}

/* justify-items:start survived from the old two-column entrance nav. Chrome
   now honours box-alignment on block containers, so it was shrink-wrapping
   every <li> to its text and every rule stopped at the end of the words. */
[data-page="entrance"] .contents ol,
.contents ol {
  justify-items: stretch;
  justify-content: normal;
}

/* --- the footer sits on one line ------------------------------------------
   The two halves were on different baselines because only the link had a
   min-height, and it was a size larger than it needed to be. */
.foot {
  align-items: center;
  font-size: 10px;
  letter-spacing: 0.12em;
  padding: 14px var(--edge);
}
.foot a,
.foot span {
  display: inline-flex;
  align-items: center;
  min-height: 28px;
  line-height: 1;
}

/* --- the artwork page holds one shape -------------------------------------
   The plate was free to be as tall as its own aspect ratio, so the arrows
   landed somewhere different on every work and you could not click through a
   run of them without chasing the pointer. Capping the plate to a fixed block
   means the pager is at the same height on all 100 pages. */
.work {
  min-height: 68svh;
  align-content: center;
}
.work .plate {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 68svh;
}
.work .plate img,
.work .plate .cv {
  width: auto;
  max-width: 100%;
  max-height: 68svh;
  object-fit: contain;
}
.pager {
  padding-top: 0;
  border-top: 1px solid var(--rule);
  margin-top: calc(var(--edge) * 1.4);
}

@media (max-width: 860px) {
  .work .plate,
  .work .plate img,
  .work .plate .cv {
    height: auto;
    max-height: 56svh;
  }
  .work { min-height: 0; }
}

/* --- no flash of the fallback list ---------------------------------------
   .ring-src is the real, crawlable index of featured works that stands in when
   scripting is off. It used to stay visible until hero.js had drawn its first
   frame, which showed a grid of twenty thumbnails for a beat on every load.
   Hidden the instant we know scripting exists; hero.js puts it BACK if it
   cannot take over, so the fallback still cannot be lost. */
[data-js] .mouth .ring-src { display: none; }
[data-js] .mouth[data-fallback] .ring-src {
  display: grid;
}

/* The pager has to be ON the first screen, or "keep clicking" still means
   scrolling to find it. Tightened so the whole artwork page — plate, title,
   enquire and both arrows — fits a 900px viewport without scrolling. */
.work {
  padding-top: calc(var(--edge) * 2);
  padding-bottom: 0;
  min-height: 0;
}
.work .plate,
.work .plate img,
.work .plate .cv {
  height: auto;
  max-height: 56svh;
}
.work .plate { height: 56svh; }
.pager {
  margin-top: var(--edge);
  padding-bottom: var(--edge);
}

/* ===================================================================
   THE FOOTER IS A COLOPHON, AND THE CONTENTS IS ONE LINE PER ITEM
   =================================================================== */

/* "write to the gallery" is gone from the footer. It was the third way to
   reach the same concierge — the contents has "contact", every artwork page
   has "enquire" — and it was the one that cost a 44px tap target and a rule
   across the bottom of every page for a link nobody needed there. What is
   left is a colophon: one line of the smallest type on the site, sitting
   under the rule and taking the height of the text and nothing more.

   The tap-target minimum goes with the link. A copyright line is not
   something anyone taps, and 28px of enforced height around 10px of type was
   most of what made the footer read as a band. */
.foot {
  padding: 9px var(--edge) 11px;
  font-size: 9px;
  letter-spacing: 0.14em;
}
.foot span,
.foot a {
  min-height: 0;
  line-height: 1.2;
}

/* A contents row is a label and a numeral on ONE line, at every width.

   It was a flex row whose label was a bare text node, so the label wrapped
   like any paragraph: on a narrow phone, or with the OS text size turned up,
   "the sequence" broke after "the" and the row became two lines tall while
   the numeral stayed put at the right. Which row broke depended on the width
   the label happened to need, so the same menu looked stacked on one device
   and not on another — the inconsistency was the bug, not the stacking.

   nowrap on the anchor covers both the bare text node and the .gw word spans
   glitch.js swaps in when the menu opens. The numeral is not allowed to
   shrink, and the label yields first if anything ever has to give. */
.contents a {
  white-space: nowrap;
  min-width: 0;
}
.contents a > span {
  flex: 0 0 auto;
}

/* And the row is the same height on every page, before and after glitch.js
   touches it.

   The height was never stated: it was whatever the line box came out at. When
   the menu opens, glitch.js replaces the label's text node with inline-block
   .gw word spans so it can drag them sideways — and an inline-block does not
   sit on the line the way a text node does, so the row lost 15px the moment
   the words were split. The entrance never gets that treatment and the inner
   pages do, which is why the same four items were loose on one page and tight
   on the next. Measured: entrance 64.1px against a work page's 49px.

   Measured after stating the line-height: a two-word row still came out 16.5px
   taller than "contact", because the space between the two .gw spans is a real
   text node and drags the strut back into a line box the inline-blocks had
   otherwise shrunk. Chasing the line box is the wrong move — every fix has to
   hold for one word, two words, split and unsplit, on two page types.

   So the row states its own height and stops asking. 52px, centred, on every
   page; the label can be anything and the row does not move. */
.contents a,
[data-page="entrance"] .contents a {
  padding: 0;
  min-height: 52px;
  line-height: 1.5;
  align-items: center;
}

/* THE LABEL WAS TAKING THE NUMERAL'S STYLING BY ACCIDENT.

   `.contents a span` was written for one span — the roman numeral at the right
   of the row — and it sets mono, 11px, uppercase. When the menu opens,
   glitch.js wraps the label's words in a plain <span> so the row keeps exactly
   two flex children (glitch.js:750-760, and it is right to do that). But that
   wrapper is also `.contents a span`, so the label picked up the numeral's
   styling: the labels were 21px EB Garamond until the moment you opened the
   menu, at which point they silently became 11px uppercase DM Mono.

   That is the difference between the two screenshots. Nothing about the page
   changed — one of them had been through the wrapper and the other had not.

   The mono is the state the site has been live in, and it stays; the accident
   was that it applied to only one of the two states. So the ROW is set in mono
   and everything inside it inherits — split, unsplit, entrance, inner page,
   scripting on or off, all four items the same. The numeral is still a size
   apart because it is still --faint against the label's --dim.

   Numerals stay unmirrored: `.contents .rev { transform: none }` above already
   guarantees nothing a visitor navigates by is ever reversed. */
.contents a,
[data-page="entrance"] .contents a {
  font-family: var(--sans);
  font-size: 11px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}
.contents a > span:first-child:not(:last-child),
.contents a span.gw {
  display: block;
  font: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
  color: inherit;
}
.contents a span.gw {
  /* .gw has to stay inline-block — glitch.js drags it sideways — but an
     inline-block sits ON the baseline, so it hangs below the line's strut and
     the row came out 63px against 62px for a single-word label. Aligning it to
     the top of the line means the line box is the strut and nothing else. */
  display: inline-block;
  vertical-align: top;
}
