/* ============================================================
   Portfolio — black ink on white paper, hairline rules.
   The chrome is monochrome; the paintings are the only colour.
   System fonts only: nothing to download, nothing to wait for.

   Written mobile-first. Breakpoints, smallest up:
     480px   large phone      wider gutters, roomier type
     620px   small tablet     grid goes to two columns
     900px   tablet           section headings go side-by-side
     1024px  laptop           full desktop nav, parallax at full strength
     1080px  laptop wide      grid goes to three columns
     1560px  large desktop    grid goes to four columns
   ============================================================ */

:root {
  --ink: #0b0b0b;
  --paper: #fbfbfa;
  --line: rgba(11, 11, 11, 0.16);
  --line-strong: rgba(11, 11, 11, 0.46);
  --muted: rgba(11, 11, 11, 0.64);
  --veil: rgba(251, 251, 250, 0.86);

  --serif: ui-serif, Georgia, "Iowan Old Style", "Times New Roman", serif;
  --sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --mono: ui-monospace, SFMono-Regular, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace;

  /* Gutters respect notches and rounded corners on phones. */
  --gutter-base: 1.25rem;
  --gutter: max(var(--gutter-base), env(safe-area-inset-left), env(safe-area-inset-right));
  --measure: 62ch;
  --hair: 1px;
  --bar-height: 3.5rem;

  /* Minimum comfortable touch target. */
  --tap: 2.75rem;

  --ease: cubic-bezier(0.22, 0.61, 0.36, 1);
  /* A pen easing: eases in gently, holds an almost even pace through the middle
     of the word, then settles. A plain ease-out sprints the first third and
     crawls the rest, which reads as mechanical rather than written. */
  --ease-write: cubic-bezier(0.42, 0.03, 0.24, 1);
  --slow: 900ms;

  color-scheme: light;
}

/* The bar grows with the mark so it never sits tight against the hairline. */
@media (min-width: 480px)  { :root { --gutter-base: 1.75rem; --bar-height: 4rem; } }
@media (min-width: 900px)  { :root { --gutter-base: 3rem; --bar-height: 4.75rem; } }
@media (min-width: 1560px) { :root { --gutter-base: 4.5rem; } }

[data-theme="dark"] {
  --ink: #f2f1ef;
  --paper: #0a0a0a;
  --line: rgba(242, 241, 239, 0.26);
  --line-strong: rgba(242, 241, 239, 0.42);
  --muted: rgba(242, 241, 239, 0.58);
  --veil: rgba(10, 10, 10, 0.86);
  color-scheme: dark;
}

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

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  /* Keeps anchor jumps clear of the fixed masthead. */
  scroll-padding-top: calc(var(--bar-height) + 1rem);
}

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 1rem;
  line-height: 1.65;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
  /* Stops iOS rubber-banding from revealing a white strip behind the page. */
  overscroll-behavior-y: none;
}

/* The UA sheet's [hidden] rule is display:none at author-origin-zero, so any
   class that sets display beats it. Several elements here are toggled by the
   .hidden property and set display via a class — make hidden actually win. */
[hidden] { display: none !important; }

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

::selection { background: var(--ink); color: var(--paper); }

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 200;
  background: var(--ink);
  color: var(--paper);
  padding: 0.75rem 1.25rem;
  font: 500 0.75rem/1 var(--mono);
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
.skip-link:focus { left: 0; }

:focus-visible {
  outline: var(--hair) solid var(--ink);
  outline-offset: 4px;
}

/* ---------------------------------------------------- shared primitives */

.shell {
  width: 100%;
  max-width: 1560px;
  margin-inline: auto;
  padding-inline: var(--gutter);
}

.rule {
  border: 0;
  border-top: var(--hair) solid var(--line);
  margin: 0;
}

.label {
  font: 500 0.625rem/1.4 var(--mono);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--muted);
}
@media (min-width: 480px) { .label { font-size: 0.6875rem; letter-spacing: 0.18em; } }

.display {
  font-family: var(--serif);
  font-weight: 400;
  line-height: 0.94;
  letter-spacing: -0.02em;
  margin: 0;
}
@media (min-width: 900px) { .display { line-height: 0.92; } }

/* ---------------------------------------------------------------- quote */

/* Used for the artist's own words — the hero intro and the statement on the
   About page. Italic serif against the page's upright serif and mono, with a
   display quote mark hanging in its own gutter so it never overflows the
   column the way true hanging punctuation would. */
.quote {
  --mark: clamp(2.4rem, 6vw, 3.6rem);
  position: relative;
  margin: 0;
  padding-left: calc(var(--mark) * 0.62);
}
.quote::before {
  content: "\201C";
  position: absolute;
  left: 0;
  top: 0;
  font: italic 400 var(--mark)/0.98 var(--serif);
  color: var(--line-strong);
  pointer-events: none;
}

.quote p {
  margin: 0;
  font-family: var(--serif);
  font-style: italic;
  font-size: clamp(1.02rem, 1.35vw, 1.2rem);
  line-height: 1.6;
  color: var(--ink);
  /* Keeps the last line from stranding a single word. */
  text-wrap: pretty;
}

/* Paragraphs separated by space, flush left — no book-style indents. */
.quote p + p {
  margin-top: 0.85em;
}

/* The closing mark rides the end of the final sentence. */
.quote p:last-child::after { content: "\201D"; }

/* ---- display variant: the hero statement ---------------------------- */

/* Set as a statement rather than a paragraph: larger, on a wider measure so
   it breaks into a few long lines instead of a tall stack of short ones, and
   with the leading opened up. */
.quote--display {
  --mark: clamp(2.8rem, 5vw, 4.2rem);
}
.quote--display p {
  font-size: clamp(1.1rem, 2vw, 1.7rem);
  line-height: 1.5;
  /* A statement measure, not a paragraph one — long enough that the text
     makes a handful of lines rather than a tall narrow stack. */
  max-width: 42ch;
  /* Muted like the label beside it, so the black belongs to the wordmark and
     the name alone. */
  color: var(--muted);
}

.prose {
  max-width: var(--measure);
  font-size: 1rem;
  line-height: 1.75;
}
@media (min-width: 480px) { .prose { font-size: 1.05rem; } }
.prose p { margin: 0 0 1.35em; }
.prose p:last-child { margin-bottom: 0; }

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

.masthead {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 100;
  border-bottom: var(--hair) solid transparent;
  transition: border-color 400ms var(--ease);
  padding-top: env(safe-area-inset-top);
}

/* The blur lives on a pseudo-element, not on .masthead itself.
   A non-none backdrop-filter makes an element a containing block for
   position:fixed descendants — with it on .masthead, the full-screen nav
   drawer inside resolved `inset: 0` against the 56px bar instead of the
   viewport, so the menu opened 120px tall with its first link clipped off
   the top. Moving the filter down a level keeps the effect and lets the
   drawer size to the screen again. */
.masthead::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: var(--veil);
  backdrop-filter: saturate(140%) blur(14px);
  -webkit-backdrop-filter: saturate(140%) blur(14px);
}

.masthead[data-stuck="true"] { border-bottom-color: var(--line); }

.masthead__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  min-height: var(--bar-height);
}

.brand {
  display: inline-flex;
  align-items: center;
  gap: 0.6rem;
  text-decoration: none;
  flex-shrink: 1;
  min-width: 0;
}
/* The wordmark is roughly 2.1:1, so it is sized by height and left to find
   its own width.
   It is drawn in the ruled-line tone rather than full ink, so it sits in the
   masthead like part of the grid rather than a badge stamped on top. Held at
   --line-strong instead of the fainter --line: any lighter and it stops being
   legible as a mark at this size. Pointing at it brings it up to full ink. */
.brand__mark {
  height: 30px;
  width: auto;
  flex: none;
  overflow: visible;
  color: var(--line-strong);
  transition: color 450ms var(--ease);
}
.brand:hover .brand__mark,
.brand:focus-visible .brand__mark { color: var(--ink); }

/* A hairline between mark and name, so the two read as one lockup rather than
   as two competing wordmarks. */
.brand__divider {
  flex: none;
  width: var(--hair);
  height: 1.25em;
  background: var(--line);
}

/* The name drops into the same mono voice as every other label on the site.
   That leaves the script as the only calligraphic element, instead of setting
   a script mark next to a tracked-out serif and letting them fight. */
.brand__name {
  font: 500 0.625rem/1 var(--mono);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  transition: color 300ms var(--ease);
}
@media (hover: hover) and (pointer: fine) {
  .brand:hover .brand__name { color: var(--ink); }
}
@media (min-width: 480px) {
  .brand__mark { height: 38px; }
  .brand { gap: 0.85rem; }
  .brand__name { font-size: 0.6875rem; letter-spacing: 0.18em; }
}
@media (min-width: 900px) {
  .brand__mark { height: 44px; }
}

.nav { display: flex; align-items: center; gap: 0.35rem; flex: none; }

.nav__link {
  position: relative;
  text-decoration: none;
  font: 500 0.6875rem/1 var(--mono);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--muted);
  transition: color 300ms var(--ease);
}
.nav__link::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: -0.35rem;
  width: 100%;
  height: var(--hair);
  background: currentColor;
  transform: scaleX(0);
  transform-origin: right;
  transition: transform 420ms var(--ease);
}
.nav__link[aria-current="page"] { color: var(--ink); }
.nav__link[aria-current="page"]::after { transform: scaleX(1); transform-origin: left; }

@media (hover: hover) and (pointer: fine) {
  .nav__link:hover { color: var(--ink); }
  .nav__link:hover::after { transform: scaleX(1); transform-origin: left; }
}

/* ---- Mobile: links live in a full-screen drawer ---------------------- */

.nav__links {
  position: fixed;
  inset: 0;
  z-index: 110;
  background: var(--paper);
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 0.25rem;
  padding: calc(var(--bar-height) + env(safe-area-inset-top) + 2rem) var(--gutter)
           calc(2rem + env(safe-area-inset-bottom));

  /* Closed state. visibility keeps the links out of the tab order. */
  opacity: 0;
  visibility: hidden;
  transform: translate3d(0, -1.5rem, 0);
  transition: opacity 320ms var(--ease), transform 320ms var(--ease), visibility 0s linear 320ms;
}
.nav__links[data-open="true"] {
  opacity: 1;
  visibility: visible;
  transform: none;
  transition-delay: 0s;
}

.nav__links .nav__link {
  font: 400 clamp(2rem, 11vw, 3.25rem)/1.15 var(--serif);
  letter-spacing: -0.01em;
  text-transform: none;
  color: var(--ink);
  padding: 0.5rem 0;
  border-bottom: var(--hair) solid var(--line);
  /* Each link slides in behind the panel fade. */
  opacity: 0;
  transform: translate3d(0, 0.75rem, 0);
  transition: opacity 400ms var(--ease), transform 400ms var(--ease);
}
.nav__links .nav__link::after { display: none; }
.nav__links[data-open="true"] .nav__link {
  opacity: 1;
  transform: none;
}
.nav__links[data-open="true"] .nav__link:nth-child(1) { transition-delay: 90ms; }
.nav__links[data-open="true"] .nav__link:nth-child(2) { transition-delay: 150ms; }
.nav__links[data-open="true"] .nav__link:nth-child(3) { transition-delay: 210ms; }
.nav__links[data-open="true"] .nav__link:nth-child(4) { transition-delay: 270ms; }

.nav__burger {
  appearance: none;
  background: transparent;
  border: 0;
  color: var(--ink);
  width: var(--tap);
  height: var(--tap);
  margin-right: -0.5rem;
  display: grid;
  place-content: center;
  gap: 6px;
  cursor: pointer;
  z-index: 120;
  flex: none;
}
.nav__burger span {
  display: block;
  width: 20px;
  height: var(--hair);
  background: currentColor;
  transition: transform 320ms var(--ease), opacity 200ms var(--ease);
}
.nav__burger[aria-expanded="true"] span:first-child { transform: translateY(3.5px) rotate(45deg); }
.nav__burger[aria-expanded="true"] span:last-child  { transform: translateY(-3.5px) rotate(-45deg); }

.theme-toggle {
  appearance: none;
  border: var(--hair) solid var(--line-strong);
  background: transparent;
  color: var(--ink);
  width: var(--tap);
  height: var(--tap);
  border-radius: 50%;
  display: grid;
  place-items: center;
  cursor: pointer;
  flex: none;
  z-index: 120;
  transition: border-color 300ms var(--ease), transform 400ms var(--ease);
}
.theme-toggle svg { width: 13px; height: 13px; }
@media (hover: hover) and (pointer: fine) {
  .theme-toggle:hover { border-color: var(--line-strong); transform: rotate(35deg); }
}

/* ---- Laptop and up: drawer dissolves back into an inline row --------- */

@media (min-width: 1024px) {
  .nav { gap: clamp(1.25rem, 2.5vw, 2.25rem); }

  .nav__links {
    position: static;
    inset: auto;
    flex-direction: row;
    align-items: center;
    gap: clamp(1.25rem, 2.5vw, 2.25rem);
    padding: 0;
    background: none;
    opacity: 1;
    visibility: visible;
    transform: none;
    transition: none;
  }
  .nav__links .nav__link {
    font: 500 0.6875rem/1 var(--mono);
    letter-spacing: 0.16em;
    text-transform: uppercase;
    color: var(--muted);
    padding: 0.4rem 0;
    border-bottom: 0;
    opacity: 1;
    transform: none;
    transition: color 300ms var(--ease);
  }
  .nav__links .nav__link::after { display: block; bottom: 0; }
  .nav__burger { display: none; }
}

/* Locks the page behind the open drawer without losing scroll position. */
body[data-nav-open="true"] { overflow: hidden; }

/* ----------------------------------------------------------------- hero */

.hero {
  position: relative;
  min-height: 100svh;
  display: flex;
  align-items: center;
  overflow: clip;
  padding-block: calc(var(--bar-height) + 3.5rem) 5rem;
}
@media (min-width: 900px) { .hero { padding-block: 9rem 6rem; } }

.hero__inner {
  position: relative;
  width: 100%;
  display: grid;
  gap: 1.5rem;
}
@media (min-width: 620px) { .hero__inner { gap: clamp(1.75rem, 4vw, 3.25rem); } }

.hero__mark {
  width: clamp(190px, 50vw, 250px);
  height: auto;
  flex: none;
  overflow: visible;
}
@media (min-width: 620px)  { .hero__mark { width: clamp(290px, 36vw, 420px); } }
@media (min-width: 1080px) { .hero__mark { width: clamp(380px, 29vw, 500px); } }

/* The letters are solid ink from the first frame. What moves is a mask — a
   wide stroke travelling along the letterform, uncovering the fill as it goes,
   so the mark appears to be written rather than outlined.
   main.js measures the true path length and sets --len; --reveal-width is the
   only value worth tuning if the sweep looks too narrow or too blunt. */
.hero__mark [data-reveal-stroke] {
  /* Each glyph starts a beat after the previous one, so the eye reads it as
     writing left to right — but every sweep is shortened by exactly the beat
     it waited, so all three arrive on the same frame. --i is stamped on each
     path by scripts/make-logo.js, and each path carries its own stroke-width
     inline (the wipe band is sized to its glyph's bounding box). */
  --start: 400ms;
  --beat: 260ms;
  --settle: 3400ms;

  stroke-dasharray: var(--len, 24000);
  stroke-dashoffset: var(--len, 24000);
  animation:
    reveal
    calc(var(--settle) - var(--i) * var(--beat))
    var(--ease-write)
    calc(var(--start) + var(--i) * var(--beat))
    forwards;
}

@keyframes reveal { to { stroke-dashoffset: 0; } }

/* The name and the statement arrive while the mark is still being written and
   settle on the same frame it does (3800ms), so the hero resolves as one
   composed moment instead of several things finishing at odd intervals.

   The wait is held inside the keyframes rather than set as animation-delay.
   A delay leaves the animation "pending", and during that phase the element
   shows its first keyframe — so anything that stops the timeline advancing
   would strand the name at opacity 0 indefinitely. Starting at 0% means the
   only way to be stuck is for animations to be broken outright, and main.js
   clears these entirely if that ever happens.

   Opacity only, no transform: these elements carry the parallax transform, and
   an animated transform would fight the inline one the script sets. */
.hero__title { animation: hero-enter-name 3800ms var(--ease-write) forwards; }
.hero__meta  { animation: hero-enter-meta 3800ms var(--ease-write) forwards; }

/* The hold percentages are the old delays over the 3800ms window: the name
   starts lifting at 2000ms, the statement at 2400ms, both land at 3800ms. */
/* The About page reuses .hero__title for its heading but has no wordmark to
   wait for, so it opts out of the home page's staged entrance. */
.hero__title--static { animation: none; }

@keyframes hero-enter-name { 0%, 52.6% { opacity: 0; } 100% { opacity: 1; } }
@keyframes hero-enter-meta { 0%, 63.2% { opacity: 0; } 100% { opacity: 1; } }


/* Pulled back from the earlier setting so the script mark above it reads as the
   primary voice and the two are not shouting over each other. */
.hero__title {
  font-size: clamp(2.5rem, 12vw, 3.5rem);
  overflow-wrap: break-word;
}
@media (min-width: 620px)  { .hero__title { font-size: clamp(3.5rem, 10vw, 6.5rem); } }
@media (min-width: 1080px) { .hero__title { font-size: clamp(5rem, 8.5vw, 8.5rem); } }

.hero__title em { font-style: italic; display: block; }

.hero__meta {
  display: grid;
  gap: 0.85rem;
  padding-top: 1.25rem;
  border-top: var(--hair) solid var(--line);
}
@media (min-width: 900px) {
  .hero__meta {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 1rem 2.5rem;
    padding-top: 1.5rem;
  }
}
/* Let the statement take the room the hero row has going spare, rather than
   being squeezed into a narrow column beside the label. */
/* The statement arrives with the fetch. Because .hero centres its column,
   growing from empty to five lines shoved the wordmark and name up ~113px —
   visible content moving, i.e. layout shift. Reserve the space. */
.hero__quote { flex: 1 1 22rem; min-height: 7.5em; }

.scroll-cue {
  position: absolute;
  left: var(--gutter);
  bottom: calc(1.25rem + env(safe-area-inset-bottom));
  display: flex;
  align-items: center;
  gap: 0.85rem;
}
/* On a short landscape phone the cue collides with the content. */
@media (max-height: 520px) { .scroll-cue { display: none; } }

.scroll-cue__line {
  display: block;
  width: 1px;
  height: 2.5rem;
  background: linear-gradient(to bottom, var(--line-strong), transparent);
  transform-origin: top;
  animation: cue 2600ms var(--ease) infinite;
}
@media (min-width: 620px) { .scroll-cue__line { height: 3.5rem; } }

@keyframes cue {
  0%, 100% { transform: scaleY(0.35); opacity: 0.35; }
  50%      { transform: scaleY(1);    opacity: 1; }
}

/* -------------------------------------------------------------- section */

.section { padding-block: clamp(3.5rem, 9vw, 8.5rem); }

.section__head {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 1rem;
  padding-bottom: clamp(1.75rem, 4vw, 3.25rem);
}
@media (min-width: 900px) {
  .section__head {
    grid-template-columns: 13rem minmax(0, 1fr);
    gap: 3rem;
    align-items: start;
  }
}

.section__title { font-size: clamp(1.5rem, 6vw, 2.75rem); }
@media (min-width: 620px) { .section__title { font-size: clamp(1.75rem, 3.4vw, 2.75rem); } }


/* ---------------------------------------------------------- works grid */

/* Seams are drawn as borders on the tiles, never as a background showing
   through a gap — a background fills the empty cells of an incomplete last
   row and reads as a grey slab (12 works hid this; 13 exposed it). Each
   breakpoint resets and redraws the seams for its column count: top seams on
   every row after the first, left seams on every column after the first. */
.works {
  --cols: 1;
  display: grid;
  grid-template-columns: repeat(var(--cols), minmax(0, 1fr));
  border-block: var(--hair) solid var(--line);
}
.work + .work { border-top: var(--hair) solid var(--line); }

@media (min-width: 620px) {
  .works { --cols: 2; }
  .work:nth-child(n) { border-top: none; border-left: none; }
  .work:nth-child(n + 3) { border-top: var(--hair) solid var(--line); }
  .work:nth-child(2n) { border-left: var(--hair) solid var(--line); }
}
@media (min-width: 1080px) {
  .works { --cols: 3; }
  .work:nth-child(n) { border-top: none; border-left: none; }
  .work:nth-child(n + 4) { border-top: var(--hair) solid var(--line); }
  .work:not(:nth-child(3n + 1)) { border-left: var(--hair) solid var(--line); }
}
@media (min-width: 1560px) {
  .works { --cols: 4; }
  .work:nth-child(n) { border-top: none; border-left: none; }
  .work:nth-child(n + 5) { border-top: var(--hair) solid var(--line); }
  .work:not(:nth-child(4n + 1)) { border-left: var(--hair) solid var(--line); }
}

/* The 1px gap over a line-coloured backdrop is what draws the grid rules —
   no per-tile borders, so seams never double up. */
.work {
  position: relative;
  background: var(--paper);
  border: 0;
  padding: 0.85rem;
  margin: 0;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  gap: 0.75rem;
  isolation: isolate;
  /* Stops the browser waiting to see if a tap is a double-tap. */
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
}
@media (min-width: 620px) { .work { padding: clamp(0.9rem, 1.8vw, 1.6rem); gap: 0.9rem; } }

.work::after {
  content: "";
  position: absolute;
  inset: 0;
  border: var(--hair) solid transparent;
  transition: border-color 400ms var(--ease);
  pointer-events: none;
}
.work:focus-visible::after { border-color: var(--line-strong); }
/* Touch devices get a press state instead of a hover state. */
.work:active::after { border-color: var(--line-strong); }

.work__frame {
  position: relative;
  overflow: hidden;
  background-size: cover;
  background-position: center;
}
.work__frame img {
  width: 100%;
  height: auto;
  opacity: 0;
  transition: opacity 700ms var(--ease), transform 900ms var(--ease);
}
.work__frame img.is-loaded { opacity: 1; }

@media (hover: hover) and (pointer: fine) {
  .work:hover::after { border-color: var(--line-strong); }
  .work:hover .work__frame img { transform: scale(1.035); }
}

.work__caption {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
  min-height: 1.5rem;
}
.work__title {
  margin: 0;
  font: 400 1rem/1.3 var(--serif);
}
@media (min-width: 480px) { .work__title { font-size: 1.0625rem; } }
.work__year { flex: none; }

.works-empty {
  padding: clamp(3rem, 8vw, 6rem) 0;
  text-align: center;
  color: var(--muted);
  border-block: var(--hair) solid var(--line);
}

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

.lightbox {
  position: fixed;
  inset: 0;
  z-index: 150;
  border: 0;
  padding: 0;
  margin: 0;
  width: 100%;
  height: 100%;
  max-width: none;
  max-height: none;
  background: var(--paper);
  color: var(--ink);
  opacity: 0;
  transition: opacity 280ms var(--ease);
  overscroll-behavior: contain;
}
.lightbox[open] { opacity: 1; }
.lightbox::backdrop { background: var(--paper); }

/* Phone: a fixed bar, the picture, then details that scroll under it. */
.lightbox__layout {
  height: 100%;
  display: grid;
  grid-template-rows: auto minmax(0, 1fr) auto;
  padding-top: env(safe-area-inset-top);
  padding-bottom: env(safe-area-inset-bottom);
}
@media (min-width: 1000px) {
  .lightbox__layout {
    grid-template-rows: auto minmax(0, 1fr);
    grid-template-columns: minmax(0, 1fr) 23rem;
  }
  .lightbox__bar { grid-column: 1 / -1; }
}

.lightbox__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.65rem var(--gutter);
  border-bottom: var(--hair) solid var(--line);
}
@media (min-width: 620px) { .lightbox__bar { padding: 0.9rem var(--gutter); } }

.lightbox__stage {
  display: grid;
  place-items: center;
  padding: 0.85rem var(--gutter);
  min-height: 0;
  overflow: hidden;
  /* Horizontal swipes are handled in JS; let the browser keep vertical scroll. */
  touch-action: pan-y;
}
@media (min-width: 620px) { .lightbox__stage { padding: clamp(1rem, 3vw, 2.5rem); } }

/* The <picture> wrapper is display:inline by default, which gives the image an
   auto-height parent — its max-height: 100% then resolves to nothing, so tall
   paintings overflowed the stage and were cropped to a "zoomed in" slice.
   display: contents removes picture from layout so the img is the grid item
   and both constraints bite. */
.lightbox__stage picture { display: contents; }

.lightbox__stage img {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  object-fit: contain;
  /* The wheel-zoom viewer drives this transform every frame. */
  transform-origin: center center;
  will-change: transform;
  user-select: none;
}
.lightbox__stage img.is-zoomed { cursor: grab; }
.lightbox__stage img.is-zoomed:active { cursor: grabbing; }

.lightbox__panel {
  padding: 1rem var(--gutter) 1.25rem;
  border-top: var(--hair) solid var(--line);
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
  display: flex;
  flex-direction: column;
  gap: 0.85rem;
  /* Never lets the text half swallow the picture on a phone. */
  max-height: 42svh;
}
@media (min-width: 1000px) {
  .lightbox__panel {
    border-top: 0;
    border-left: var(--hair) solid var(--line);
    max-height: none;
    padding: clamp(1.25rem, 3vw, 2.25rem) var(--gutter);
    gap: 1.25rem;
  }
}

.lightbox__title {
  margin: 0;
  font: 400 clamp(1.25rem, 5.5vw, 1.9rem)/1.15 var(--serif);
}

.lightbox__facts {
  margin: 0;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: 0.35rem 1rem;
  font-size: 0.9375rem;
}
.lightbox__facts dt {
  color: var(--muted);
  font: 500 0.625rem/1.7 var(--mono);
  letter-spacing: 0.14em;
  text-transform: uppercase;
}
.lightbox__facts dd { margin: 0; }

.lightbox__desc {
  color: var(--muted);
  font-size: 0.9375rem;
  line-height: 1.75;
  white-space: pre-line;
}

/* A hint that the picture can be swiped, shown only on touch devices. */
.lightbox__swipe-hint { display: none; }
@media (hover: none) and (max-width: 999px) {
  .lightbox__swipe-hint { display: block; text-align: center; }
}

.icon-btn {
  appearance: none;
  background: transparent;
  /* An interactive border needs 3:1 against the page (WCAG 1.4.11);
     --line is decorative and far below that. */
  border: var(--hair) solid var(--line-strong);
  color: var(--ink);
  width: var(--tap);
  height: var(--tap);
  display: grid;
  place-items: center;
  cursor: pointer;
  flex: none;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  transition: border-color 300ms var(--ease);
}
.icon-btn:disabled { opacity: 0.3; cursor: default; }
.icon-btn svg { width: 15px; height: 15px; }
@media (hover: hover) and (pointer: fine) {
  .icon-btn { width: 2.25rem; height: 2.25rem; }
  .icon-btn svg { width: 14px; height: 14px; }
  .icon-btn:hover:not(:disabled) { border-color: var(--line-strong); }
}

.lightbox__nav { display: flex; gap: 0.4rem; align-items: center; }

/* -------------------------------------------------------------- contact */

.contact__list {
  list-style: none;
  margin: 0;
  padding: 0;
  border-top: var(--hair) solid var(--line);
}
.contact__row {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: 0.3rem;
  padding: 1.1rem 0;
  border-bottom: var(--hair) solid var(--line);
}
@media (min-width: 720px) {
  .contact__row { grid-template-columns: 10rem minmax(0, 1fr); gap: 2rem; align-items: baseline; padding: 1.3rem 0; }
}
.contact__value {
  font: 400 clamp(1.05rem, 4.5vw, 1.5rem)/1.35 var(--serif);
  text-decoration: none;
  position: relative;
  display: inline-block;
  /* Long emails and handles must not push the page sideways. */
  overflow-wrap: anywhere;
}
@media (min-width: 720px) { .contact__value { font-size: clamp(1.05rem, 2vw, 1.5rem); } }

a.contact__value::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: -2px;
  width: 100%;
  height: var(--hair);
  background: currentColor;
  transform: scaleX(0);
  transform-origin: right;
  transition: transform 480ms var(--ease);
}
@media (hover: hover) and (pointer: fine) {
  a.contact__value:hover::after { transform: scaleX(1); transform-origin: left; }
}
/* Touch: the underline is always there, since there is no hover to reveal it. */
@media (hover: none) {
  a.contact__value::after { transform: scaleX(1); opacity: 0.35; }
}

/* --------------------------------------------------------------- events */

.events[hidden] { display: none; }

/* The arrows live beside the heading, so the section opens exactly like the
   works section rather than growing an extra control row of its own. */
.events__headline {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 1.5rem;
}
.events__nav { display: flex; gap: 0.4rem; flex: none; }
/* Native scrolling is the interaction on touch; the arrows are for pointers. */
@media (hover: none) { .events__nav { display: none !important; } }

.events__foot {
  display: flex;
  align-items: center;
  gap: 1.25rem;
  padding-top: 1.25rem;
}
.events__foot[hidden] { display: none; }
.events__hint { margin: 0; flex: none; }
@media (hover: hover) { .events__hint { display: none; } }

.events__viewport {
  display: flex;
  /* No background here. The seams are drawn by a border on each card, because
     a background on the track shows through anywhere a card does not reach
     and reads as a grey slab at the ends. */
  border-block: var(--hair) solid var(--line);

  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-behavior: smooth;
  -webkit-overflow-scrolling: touch;

  /* One card fills a phone screen, so hard snapping is what you want there.
     On wider screens several cards are visible at once and mandatory snapping
     fights the user near the ends — proximity only snaps when already close. */
  scroll-snap-type: x mandatory;

  /* Hide the scrollbar without losing the ability to scroll. */
  scrollbar-width: none;
}
.events__viewport::-webkit-scrollbar { display: none; }
.events__viewport:focus-visible { outline: var(--hair) solid var(--ink); outline-offset: -1px; }

@media (prefers-reduced-motion: reduce) { .events__viewport { scroll-behavior: auto; } }

/* Card widths are a fraction of the track, matched to the works grid's column
   count at each breakpoint — so the two sections share one rhythm instead of
   each inventing its own. box-sizing keeps the seam border inside the width,
   so N cards fill the row exactly. */
.event {
  flex: 0 0 auto;
  width: 86%;
  scroll-snap-align: start;
  background: var(--paper);
  padding: 0.85rem;
  display: flex;
  flex-direction: column;
  gap: 0.9rem;
}
.event + .event { border-left: var(--hair) solid var(--line); }

@media (min-width: 620px) {
  .events__viewport { scroll-snap-type: x proximity; }
  .event { width: 50%; padding: clamp(0.9rem, 1.8vw, 1.6rem); }
}
@media (min-width: 1080px) { .event { width: 33.3333%; } }
@media (min-width: 1560px) { .event { width: 25%; } }

.event__frame {
  position: relative;
  overflow: hidden;
  background-size: cover;
  background-position: center;
  border: var(--hair) solid var(--line);
}
.event__frame img {
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 3;
  object-fit: cover;
  opacity: 0;
  transition: opacity 700ms var(--ease);
}
.event__frame img.is-loaded { opacity: 1; }

/* Without a photo the card leans on type instead of leaving a hole. */
.event__rule {
  border: 0;
  border-top: var(--hair) solid var(--line-strong);
  margin: 0;
}

.event__body { display: flex; flex-direction: column; gap: 0.6rem; }

.event__venue { margin: 0; }

.event__title {
  margin: 0;
  font: 400 clamp(1.15rem, 3.5vw, 1.5rem)/1.25 var(--serif);
}

.event__dates {
  margin: 0;
  font: 500 0.6875rem/1.5 var(--mono);
  letter-spacing: 0.08em;
  color: var(--ink);
}

.event__desc {
  margin: 0;
  color: var(--muted);
  font-size: 0.9375rem;
  line-height: 1.7;
}

.event__link {
  align-self: start;
  margin-top: 0.15rem;
  font: 500 0.625rem/1 var(--mono);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  text-decoration: none;
  padding-bottom: 0.35rem;
  border-bottom: var(--hair) solid var(--line-strong);
}
@media (hover: hover) and (pointer: fine) {
  .event__link { transition: border-color 300ms var(--ease); }
  .event__link:hover { border-bottom-color: var(--ink); }
}

.events__progress {
  flex: 1 1 auto;
  height: var(--hair);
  background: var(--line);
  overflow: hidden;
}
.events__progress-bar {
  display: block;
  height: 100%;
  width: 0;
  /* The muted tone, not full ink — it is a position indicator, not a rule. */
  background: var(--line-strong);
  transition: width 180ms var(--ease);
}

/* --------------------------------------------------------------- social */

.social { display: flex; gap: 0.6rem; }

/* Square hairline tiles, the same shape language as the lightbox and slider
   controls — so the social links read as part of the interface rather than
   as pasted-in brand badges. */
.social__link {
  display: grid;
  place-items: center;
  width: var(--tap);
  height: var(--tap);
  border: var(--hair) solid var(--line-strong);
  color: var(--muted);
  text-decoration: none;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  transition: color 320ms var(--ease), border-color 320ms var(--ease);
}
.social__link svg { width: 17px; height: 17px; }
@media (hover: hover) and (pointer: fine) {
  .social__link:hover { color: var(--ink); border-color: var(--line-strong); }
}
.social__link:active { color: var(--ink); border-color: var(--line-strong); }

/* ------------------------------------------------------------------- cv */

.contact__cv {
  display: flex;
  align-items: center;
  gap: 1.25rem;
  flex-wrap: wrap;
  padding-top: 1.75rem;
}
.contact__cv[hidden] { display: none; }

/* A hairline-boxed link rather than a filled button — the page has no solid
   shapes anywhere else and one would sit outside the language. */
.button-line {
  display: inline-flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.85rem 1.4rem;
  border: var(--hair) solid var(--line-strong);
  text-decoration: none;
  font: 500 0.6875rem/1 var(--mono);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  min-height: var(--tap);
  transition: border-color 350ms var(--ease), background 350ms var(--ease), color 350ms var(--ease);
}
.button-line svg { width: 14px; height: 14px; flex: none; }

@media (hover: hover) and (pointer: fine) {
  .button-line:hover { background: var(--ink); color: var(--paper); border-color: var(--ink); }
}
.button-line:active { background: var(--ink); color: var(--paper); border-color: var(--ink); }

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

.about__layout { display: grid; gap: 2rem; }
@media (min-width: 960px) {
  /* Without its frame the portrait can carry more of the column. */
  .about__layout {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
    gap: clamp(2.5rem, 6vw, 5rem);
    align-items: start;
  }
}

/* No frame. The portrait is a cut-out, so a box around it reads as a box with
   a person floating inside; unframed, the subject simply stands on the page. */
.about__portrait {
  position: relative;
  margin: 0;
  max-width: none;
}
@media (min-width: 960px) {
  .about__portrait { position: sticky; top: calc(var(--bar-height) + 2rem); }
}

.about__portrait img {
  width: 100%;
  filter: grayscale(1) contrast(1.05);
  transition: filter 900ms var(--ease);
}
@media (hover: hover) and (pointer: fine) {
  .about__portrait:hover img { filter: grayscale(0) contrast(1); }
}
/* No hover on touch, so the portrait simply stays in colour. */
@media (hover: none) {
  .about__portrait img { filter: none; }
}

/* The caption sits under the picture as a plain label — no rule, since the
   frame it used to close is gone. */
.about__portrait figcaption { padding-top: 1.1rem; }

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

.footer {
  border-top: var(--hair) solid var(--line);
  padding-block: 2rem;
  padding-bottom: calc(2rem + env(safe-area-inset-bottom));
}
.footer__inner {
  display: flex;
  flex-wrap: wrap;
  gap: 0.75rem 2rem;
  align-items: center;
  justify-content: space-between;
}

/* The mark returns once at the foot of the page, closing the frame it opened. */
.footer__brand {
  display: inline-flex;
  align-items: center;
  gap: 0.7rem;
}
/* Same ruled-line tone as the masthead, so the two marks read as a pair. */
.footer__mark {
  height: 26px;
  width: auto;
  flex: none;
  color: var(--line-strong);
}

/* -------------------------------------------------------------- reveals */

[data-reveal] {
  opacity: 0;
  transform: translate3d(0, 22px, 0);
  transition: opacity var(--slow) var(--ease), transform var(--slow) var(--ease);
  transition-delay: var(--delay, 0ms);
}
[data-reveal].is-visible { opacity: 1; transform: none; }

/* A hairline that draws itself in as its section arrives. */
[data-draw] {
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 1100ms var(--ease);
}
[data-draw].is-visible { transform: scaleX(1); }

/* ----------------------------------------------------- reduced motion */

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
  [data-reveal] { opacity: 1; transform: none; }
  [data-draw] { transform: scaleX(1); }
  /* Animations are suppressed above, so the sweeps would never run and the
     masks would hide the mark forever. main.js also drops them outright, but
     set the finished state here too in case the script never arrives. */
  .hero__mark [data-reveal-stroke] { stroke-dashoffset: 0 !important; }
  .nav__links .nav__link { opacity: 1; transform: none; }
}

/* Every failsafe for these lives in main.js — which is no help if the script
   is the thing that never arrived. Without it the headings, contact list and
   CV block would stay at opacity 0 on an otherwise-rendered page. */
@media (scripting: none) {
  [data-reveal] { opacity: 1 !important; transform: none !important; }
  [data-draw] { transform: scaleX(1) !important; }
  .hero__title, .hero__meta { animation: none !important; }
}

@media print {
  .masthead, .scroll-cue, .theme-toggle, .lightbox, .nav__burger { display: none !important; }
  body { background: #fff; color: #000; }
  .works { --cols: 2; }
}
