:root {
  --bg: #0a0a0b;
  --fg: rgba(255,255,255,0.93);
  --fg-mid: rgba(255,255,255,0.58);
  --fg-dim: rgba(255,255,255,0.34);
  --border: rgba(255,255,255,0.09);
  --font-ui: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Helvetica, Arial, sans-serif;
  --font-title: 'Albert Sans', sans-serif;

  /* Spacing, tightened from the original mockup values and driven from one
     place so the Appearance panel can set them. */
  --section-gap: 18px;      /* space above a divider rule (was 26px) */
  --section-pad: 15px;      /* space below that rule (was 22px)      */
  --row-gap: 30px;          /* between projects on the work grid (was 46px) */
  --page-pad-y: 20px;       /* top and bottom page padding (was 28-40px)   */
  --page-pad-x: 40px;

  /* How much a project thumbnail grows under the cursor. 1 is no movement;
     1.022 is a 2.2% lift. Set from Appearance → Work grid. */
  --hover-scale: 1.022;
  --hover-lift: 1.05;       /* brightness under the cursor */
}

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

* { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--fg);
  font-family: var(--font-ui);
  -webkit-font-smoothing: antialiased;
  min-height: 100vh;
}

button, a { font-family: inherit; }
img { max-width: 100%; }

.page[hidden] { display: none; }

/* ---------- Site nav (shared header) ---------- */
.site-nav {
  display: flex;
  flex-direction: column;
  /* Left, centred or right, set in the CMS. */
  align-items: var(--menu-align, center);
  gap: 16px;
  padding: var(--page-pad-y) var(--page-pad-x) 16px;
  border-bottom: 0.5px solid var(--border);
  /* Above the contact page's phone backdrop, which is a positioned element
     and would otherwise paint over this un-positioned header. */
  position: relative;
  z-index: 3;
}
.site-nav[hidden] { display: none; }

.brand {
  flex-shrink: 0;
  text-align: var(--menu-text-align, center);
  background: none;
  border: none;
  cursor: pointer;
  color: inherit;
  font: inherit;
  padding: 0;
}
.brand-name {
  display: block;
  font-family: var(--font-title);
}
.brand-role {
  display: block;
  margin-top: 4px;
}

.nav-links {
  display: flex;
  align-items: center;
  justify-content: var(--menu-align, center);
  flex-wrap: wrap;
  gap: 14px 30px;
}

.nav-link {
  white-space: nowrap;
  background: none;
  border: none;
  cursor: pointer;
  transition: color 0.2s;
}
.nav-link.active { color: var(--fg); }
/* Showreel reads as a plain menu item like the rest — no box around it. */
.nav-link.reel { color: var(--fg); }

/* ---------- Title / hero splash ---------- */
.page-title { min-height: 100vh; }

.hero {
  position: relative;
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  overflow: hidden;
  width: 100%;
  text-align: center;
}

.hero-slides { position: absolute; inset: 0; }
.hero-slide {
  position: absolute;
  inset: 0;
  background-size: cover;
  /* Default when a slide has no hotspot set; overridden inline per-slide in
     renderHero() from the still's own focal point otherwise. */
  background-position: center 35%;
  filter: brightness(var(--hero-brightness, 0.42)) saturate(0.85);
  opacity: 0;
  transition: opacity var(--hero-fade, 0.35s) ease-in-out;
}
.hero-slide.active { opacity: 1; }
.hero-video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: brightness(var(--hero-brightness, 0.42)) saturate(0.85);
}

.hero-vignette {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(ellipse 90% 80% at 50% 45%, transparent 20%, rgba(0,0,0,0.55) 100%);
}

.hero-text { position: relative; z-index: 2; }
.hero-name {
  display: block;
  white-space: nowrap;
  font-size: clamp(14px, 4.4vw, 34px);
  font-family: var(--font-title);
  font-weight: 300;
  letter-spacing: 0.2em;
  color: rgba(255,255,255,0.95);
  margin-bottom: 10px;
  text-shadow: 0 2px 24px rgba(0,0,0,0.5);
}
.hero-role {
  display: block;
  margin-bottom: 40px;
}

/* ---------- Work grid ---------- */
.work-grid { padding: var(--page-pad-y) var(--page-pad-x) 10px; }

.project-tile {
  display: block;
  position: relative;
  width: var(--thumb-width, 95%);
  margin: 0 auto var(--row-gap);
  text-align: left;
  background: none;
  border: none;
  cursor: pointer;
  padding: 0;
  /* A link element that is briefly a real link on a right-click or a
     new-tab click (linkOnDemand in js/grid.js) — never blue or underlined
     for that moment. */
  color: inherit;
  text-decoration: none;
  /* margin-top/bottom: reserved by attachHoverFilm() in js/film.js when an
     enlarged hover video would otherwise overhang the nav above or the
     next project below — animates the page apart to make room instead. */
  transition: transform 0.42s cubic-bezier(.22,.61,.36,1), filter 0.42s,
    margin-top 0.42s cubic-bezier(.22,.61,.36,1), margin-bottom 0.42s cubic-bezier(.22,.61,.36,1);
  transform-origin: center center;
}
.project-tile:hover {
  /* `none` in video mode, where the magnify is deliberately suppressed.
     It was resolving to scale(1) — a no-op that still promoted the tile to
     its own compositing layer, and a layer with a fractional bottom edge
     (rows are 142.91px tall) seams against the page beneath it. */
  transform: var(--hover-transform, scale(var(--hover-scale)));
  filter: brightness(var(--hover-lift));
  /* The scale transform starts a new stacking context, so without this the
     tile below (later in the page) paints over a video that grows downward
     out of its own row — visible above, silently hidden below. */
  position: relative;
  z-index: 5;
}

.frames {
  position: relative;
  background: var(--bg, #0a0a0b);
  line-height: 0;
  /* Still needed horizontally — the frames past the visible count wait just
     off the right edge for hover-drift to slide them in; vertically this
     never clips now that height below is a floor, not a fixed height. */
  overflow: hidden;
  /* Set from the CMS's Thumbnail height field. This is a FLOOR for the row,
     not a fixed height — a frame always keeps the aspect ratio it was
     imported at (see .frame below) and is never stretched or cropped to
     fit it. If a project's natural row height (driven by its own frames'
     ratios) is shorter than this, the extra space shows the page
     background above/below, centered. If it's taller — e.g. a 4:3 project
     next to 2.39:1 ones — the row simply grows to fit it uncropped; height
     (not min-height) here would silently clip that taller row instead. */
  min-height: var(--thumb-height, auto);
  display: flex;
  align-items: center;
}

/* Holds every still, not just the ones on screen; the rest wait past the
   right edge until a hover slides them in. */
.frames-track {
  display: flex;
  gap: var(--frame-gap, 5.4px);
  width: 100%;
  transition: filter 0.3s ease;
}
.frames-track.drifting { will-change: transform; }
/* Set on the tile alongside .tile-video.playing (attachHoverFilm in app.js)
   — brightness only, so the frame still holds its shape behind the film. */
/*
 * Dimming the stills behind a playing film.
 *
 * This was `filter: brightness()` on the track. A filter promotes its
 * element to a compositing layer, the track's height is fractional, and a
 * fractional layer blended over the page during a 0.3s transition leaves a
 * hairline along its bottom edge — the flash reported along the bottom of
 * the thumbnails, sporadic because it depends where the fraction and the
 * animation land.
 *
 * A plain overlay instead: no filter, no layer, and .frames clips it to
 * exactly the row. It sits under .tile-video (z-index 2) so the film itself
 * is never dimmed, and under the caption for the same reason.
 */
.frames::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  background: #000;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.3s ease;
}
.project-tile.dimming .frames::after { opacity: var(--hover-dim-veil, 0); }

@keyframes tile-drift {
  from { transform: translateX(0); }
  to   { transform: translateX(var(--drift, 0)); }
}

/* Only where there is a real cursor to hover with — on a touch screen the
   strip would either never move or never stop. */
@media (hover: hover) {
  .project-tile:hover .frames-track.drifting {
    animation: tile-drift var(--drift-dur, 14s) linear infinite;
  }
}

@media (prefers-reduced-motion: reduce) {
  .frames-track.drifting { animation: none !important; }
}

/* How many frames show on a phone is worked out in app.js, which has the
   aspect ratios to hand — the ones past the count sit beyond the right edge
   and the strip's overflow hides them. */
/* The cell's aspect-ratio box is the whole truth about its size, so the
   image inside must never be able to make it taller. Without this, a still
   swapped in on hover (hydrateFrames) overflowed its cell for a frame or two
   while it decoded — which grew the cell, grew the track, grew the row, and
   painted about 2px of picture below the thumbnails before snapping back.
   That is the line that flashed along the bottom edge: measured appearing
   ~200ms after hover and gone by ~290ms, bright against the page. */
.frame { min-width: 0; flex: 0 0 auto; overflow: hidden; }
/* Fills the wrapper, which carries the still's own true aspect ratio (set
   inline by addFrame() in app.js) — so a frame holds its shape before its
   image has arrived. No image is ever server-cropped, so cover here never
   changes what is shown; it just avoids a stray sub-pixel sliver of
   background at the frame's own edge. */
.frame img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* The film, played over the middle frame while the cursor rests on a project.
   Position and width are set in app.js from that frame's own box, since which
   frame is the middle one depends on how many are showing. */
.tile-video {
  position: absolute;
  z-index: 2;
  overflow: hidden;
  /* Its own ground, so a half-pixel of rounding along an edge shows as the
     film's dark surround rather than a strip of the photograph underneath. */
  background: #0a0a0b;
  opacity: 0;
  pointer-events: none;
  box-sizing: border-box;
  border: var(--hover-border-width, 1px) solid var(--hover-border-color, rgba(0,0,0,1));
  transition: opacity 0.3s ease;
}
/*
 * While actually showing, the film catches the pointer itself, rather than
 * letting it pass through to whatever sits underneath.
 *
 * A film large enough to overhang its own tile is visually sitting on top
 * of the project above or below — and pointer-events: none (needed so a
 * click on a WARM, invisible film still reaches the tile's own button, not
 * a hidden iframe) let that overhang forward every hover and click straight
 * through to the neighbour's real picture underneath, as if the film
 * covering it were not there at all. Hovering it ended the current film
 * and started the neighbour's — and clicking it opened the neighbour's
 * project, not the one plainly on screen. Restored only once playing, so
 * the fix costs nothing while a film is warm and invisible.
 *
 * The iframe itself stays non-interactive (below), so the click still
 * bubbles to this tile's own button exactly as it always has — it now
 * simply starts from the film's own edges instead of skipping past them.
 */
.tile-video.playing {
  opacity: 1;
  box-shadow: var(--hover-border-shadow, none);
  pointer-events: auto;
}
.tile-video iframe {
  pointer-events: none;
}

.tile-caption {
  position: absolute;
  text-align: center;
  pointer-events: none;
  line-height: normal;
  /* Above the dimming veil, which the brightness filter it replaced never
     touched either — the title stays lit while the stills behind it drop. */
  z-index: 2;
}

/* Where the title sits on the frame, set in the CMS. Top and bottom carry a
   gradient so the text holds against a bright frame; the middle has no edge to
   anchor to, so it takes an even wash across the whole thumbnail instead. */
.tile-caption.at-bottom {
  inset: auto 0 0 0;
  padding: 22px 18px 17px;
  background: linear-gradient(to top, rgba(0,0,0,0.74) 0%, rgba(0,0,0,0.3) 55%, transparent 100%);
}
.tile-caption.at-top {
  inset: 0 0 auto 0;
  padding: 17px 18px 22px;
  background: linear-gradient(to bottom, rgba(0,0,0,0.74) 0%, rgba(0,0,0,0.3) 55%, transparent 100%);
}
.tile-caption.at-middle {
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 18px;
  background: rgba(0, 0, 0, 0.34);
}
/* Set below the image instead: no scrim to fight, and nothing to clip it. */
.tile-caption.below {
  position: static;
  padding: 11px 2px 0;
  background: none;
}
.tile-caption.below .tile-title,
.tile-caption.below .tile-credit { text-shadow: none; }
.tile-title {
  margin-bottom: 6px;
  text-shadow: 0 1px 12px rgba(0,0,0,0.6);
}
.tile-credit {
  font-size: 10px;
  letter-spacing: 0.14em;
  color: rgba(255,255,255,0.62);
  text-shadow: 0 1px 10px rgba(0,0,0,0.6);
}
/* With no credit beneath it the title is the whole caption, so drop its gap. */
.tile-title:last-child { margin-bottom: 0; }

/* ---------- Footer ---------- */
.site-footer {
  display: flex;
  justify-content: space-between;
  gap: 20px;
  padding: 18px var(--page-pad-x);
  border-top: 0.5px solid var(--border);
}
.site-footer a { color: inherit; text-decoration: none; transition: color 0.2s; }
.site-footer a:hover { color: rgba(255,255,255,0.58); }

/* ---------- Project detail ---------- */
.project-back-row {
  max-width: 1100px;
  margin: 0 auto;
  padding: 16px var(--page-pad-x) 0;
}
.back-link {
  font-size: 10px;
  letter-spacing: 0.16em;
  color: var(--fg-dim);
  background: none;
  border: none;
  cursor: pointer;
  padding: 0;
  transition: color 0.2s;
}
.back-link:hover { color: var(--fg); }

.project-detail { max-width: 1100px; margin: 0 auto; padding: 0 40px; }
/* The stills gallery can be set wider than this column and breaks out of it
   (see .stills-section). Clipping here keeps that contained to the window
   instead of adding a sideways scrollbar — `clip` rather than `hidden` so it
   doesn't turn the page into a scroll container. */
.page-project { overflow-x: clip; }

.video-frame {
  /* A button, so it is reachable by keyboard and announces itself. */
  appearance: none;
  font: inherit;
  color: inherit;
  border: none;
  padding: 0;
  /* A button shrinks to its content; a div filled its parent. Without this the
     hero collapses to the size of the play mark. */
  width: 100%;
  position: relative;
  aspect-ratio: 16 / 9;
  background: #08090a;
  overflow: hidden;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-top: 20px;
  transition: filter 0.4s;
}
.video-frame.playable { cursor: pointer; }
/* Video frames brighten under the cursor but do not scale — the magnification
   suits the work-grid thumbnails, not a full-width player. */
.video-frame.playable:hover { filter: brightness(1.08); }
.video-frame-bg {
  position: absolute;
  inset: 0;
  background-size: cover;
  background-position: center;
  filter: brightness(0.5);
}
/* Stroke-only triangle, sized and weighted from the CMS. No container, no
   plate, no hover of its own — the hero already brightens under the cursor. */
.play-mark {
  position: relative;
  z-index: 2;
  display: block;
}
.video-badge {
  position: absolute;
  bottom: 14px;
  right: 16px;
  z-index: 3;
  font-size: 9px;
  letter-spacing: 0.14em;
  color: rgba(255,255,255,0.3);
  border: 0.5px solid rgba(255,255,255,0.14);
  padding: 4px 10px;
  border-radius: 20px;
}

.detail-header { padding: 18px 0 0; text-align: center; }
.detail-title {
  margin: 0 0 9px;
}
.detail-subline {}
/* A title with no subline, awards, or copy under it shouldn't trail a margin. */
.detail-title:last-child { margin-bottom: 0; }

/* The pills' own row, for when they are set below the credits rather than
   in among them (Appearance → Project page → Awards & logos). Tighter than
   the credits row's gap, since these are one kind of thing repeated. */
.awards-row {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px;
  margin-top: 20px;
}

/* Two lines: the award, then what it was for. The second sits quieter, so
   the pill still reads as one thing rather than two stacked labels.
   Pills are laid out by whichever row they sit in — the credits' or their
   own — so they only carry their own shape. */
.award-pill {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  /* Centred against the credits beside it, which are shorter. Without this
     the border stretches to the tallest thing on the line. */
  align-self: center;
  gap: 3px;
  font-size: 9px;
  letter-spacing: 0.16em;
  color: rgba(255,255,255,0.72);
  border: 0.5px solid rgba(255,255,255,0.24);
  padding: 7px 13px;
  text-align: center;
}
.award-detail {
  font-size: 8.5px;
  letter-spacing: 0.12em;
  color: rgba(255,255,255,0.46);
}

/* Two rows — awards, then broadcasters — each with its own size, spacing
   and opacity from Appearance → Project page. The fallbacks are the single
   fixed row these replaced, so a page with none of it set is unchanged. */
.logos-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  margin-top: 20px;
}
.logos-award { gap: var(--award-logo-gap, 26px); }
.logos-broadcast { gap: var(--broadcast-logo-gap, 26px); }
/* A second row follows the first more closely than it follows the awards
   above it — they read as one block of marks, not two sections. */
.logos-award + .logos-broadcast { margin-top: 12px; }

.logo-slot {
  /* object-fit keeps each logo's own proportions inside the box, so a wide
     laurel and a square network mark never get stretched to match. */
  object-fit: contain;
  display: block;
}
.logos-award .logo-slot {
  width: var(--award-logo-w, 120px);
  height: var(--award-logo-h, 64px);
  opacity: var(--award-logo-opacity, 0.85);
}
.logos-broadcast .logo-slot {
  width: var(--broadcast-logo-w, 120px);
  height: var(--broadcast-logo-h, 64px);
  opacity: var(--broadcast-logo-opacity, 0.85);
}

.detail-copy {
  font-size: 13px;
  line-height: 1.75;
  color: var(--fg-mid);
  text-wrap: pretty;
  margin: 16px auto 0;
  max-width: 660px;
}

.credits-section, .specs-section {
  margin-top: var(--section-gap);
  padding-top: var(--section-pad);
  border-top: 0.5px solid var(--border);
  text-align: center;
}
.credits-row, .specs-row {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 14px 34px;
}
/* Credits wrap awkwardly two-and-three across on a phone, so they stack. */
@media (max-width: 620px) {
  .credits-row { flex-direction: var(--mobile-credits-dir, column); gap: 18px; }
}

.credit-role, .spec-key {
  font-size: 9px;
  letter-spacing: 0.14em;
  color: var(--fg-dim);
  margin-bottom: 5px;
}
.credit-name { font-size: 12px; color: var(--fg-mid); }
.credit-name.lead {}
.spec-val { font-size: 12px; color: rgba(255,255,255,0.58); }

/* The gallery can run wider than the text column it sits in (Appearance →
   Project page → Stills gallery width). Centred on its own container and
   pulled back by half its own width, so it grows evenly to both sides
   rather than pushing off to the right; at the default 100% this resolves
   to exactly where it would have sat anyway. Capped at the window width so
   it can never cause a sideways scroll. */
.stills-section {
  margin-top: var(--section-gap);
  padding-top: var(--section-pad);
  border-top: 0.5px solid var(--border);
  width: var(--stills-width, 100%);
  max-width: 100vw;
  margin-left: 50%;
  transform: translateX(-50%);
}
.stills-label {
  font-size: 9px;
  letter-spacing: 0.24em;
  color: var(--fg-dim);
  margin-bottom: 12px;
  text-align: center;
}
.stills-viewport {
  position: relative;
  width: 100%;
  height: var(--stills-height, 200px);
  background: #0e0f10;
  overflow: hidden;
}
.stills-strip {
  display: flex;
  gap: 6px;
  height: 100%;
  transition: transform var(--stills-dur, 600ms) cubic-bezier(.22,.61,.36,1);
}
.stills-strip img { height: var(--stills-height, 200px); width: auto; flex-shrink: 0; display: block; object-fit: cover; }

/* A full-height gutter at each edge, carrying the mark over a scrim that fades
   away from the edge — what keeps a thin stroke legible on a bright still. */
.stills-nav {
  position: absolute;
  top: 0;
  height: 100%;
  width: var(--arrow-gutter, 46px);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: none;
  cursor: pointer;
  transition: background 0.25s;
}
.stills-prev { left: 0; background: linear-gradient(to right, rgba(0,0,0,var(--arrow-scrim, 0.5)), transparent); }
.stills-next { right: 0; background: linear-gradient(to left, rgba(0,0,0,var(--arrow-scrim, 0.5)), transparent); }
.stills-prev:hover { background: linear-gradient(to right, rgba(0,0,0,var(--arrow-scrim-hover, 0.72)), transparent); }
.stills-next:hover { background: linear-gradient(to left, rgba(0,0,0,var(--arrow-scrim-hover, 0.72)), transparent); }
.stills-nav:focus-visible { outline: 2px solid rgba(255,255,255,0.85); outline-offset: -4px; }

/* One set of points, mirrored — two would drift apart the first time the head
   angle changed. */
.stills-next .arrow-mark { transform: scaleX(-1); }

.pager {
  max-width: 1100px;
  margin: 30px auto 0;
  display: grid;
  grid-template-columns: 1fr 1fr;
  border-top: 0.5px solid var(--border);
}
@media (max-width: 900px) { .pager { grid-template-columns: 1fr; } }
.pager-btn {
  padding: 26px 40px;
  text-align: left;
  background: none;
  border: none;
  cursor: pointer;
  transition: background 0.25s;
}
.pager-btn:hover { background: rgba(255,255,255,0.035); }
.pager-prev { border-right: 0.5px solid var(--border); }
@media (max-width: 900px) {
  .pager-prev { border-right: none; border-bottom: 0.5px solid var(--border); }
}
.pager-next { text-align: right; }
@media (max-width: 900px) { .pager-next { text-align: left; } }
.pager-label { font-size: 9px; letter-spacing: 0.2em; color: var(--fg-dim); margin-bottom: 7px; }
.pager-title { font-size: 13px; letter-spacing: 0.1em; color: rgba(255,255,255,0.58); }

/* ---------- Showreel ---------- */
.page-showreel { min-height: 100vh; display: flex; flex-direction: column; }
.showreel-stage {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 50px 40px;
}
.showreel-stage .video-frame {
  /* A button, so it is reachable by keyboard and announces itself. */
  appearance: none;
  font: inherit;
  color: inherit;
  border: none;
  padding: 0; width: 100%; max-width: 1100px; margin-top: 0; }

/* ---------- Contact ---------- */
/* Full-bleed rather than the old fixed 220px strip — capped both ends so it
   reads as a proper banner on any window height. */
.contact-banner {
  position: relative;
  height: var(--banner-height, 52vh);
  min-height: 260px;
  max-height: 900px;
  overflow: hidden;
  border-bottom: 0.5px solid var(--border);
}
.contact-banner img {
  width: 100%; height: 100%; object-fit: cover; object-position: center; display: block;
  opacity: 0; transition: opacity 0.4s ease;
}
.contact-banner img.loaded { opacity: 1; }
/* The darkening over the photo. Style, strength and how far a gradient
   reaches all come from the CMS (Contact page → Banner) — the defaults here
   are the original look, for a page rendered before any of it was set. */
.contact-banner-scrim {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: var(--banner-scrim, linear-gradient(to top, rgba(10,10,11,0.92) 0%, rgba(10,10,11,0.45) 42%, rgba(10,10,11,0) 72%));
}

/*
 * The contact page's spacing, in one place, so a phone can differ from a
 * desktop without every rule below being written twice.
 *
 * Each of these resolves to the desktop value; the phone block near the end
 * of this section re-points them at the phone's own settings. Rules use
 * these rather than the --panel-* ones directly, which is what keeps the two
 * screens independent — adjusting one used to move the other.
 */
.page-contact {
  --c-pad-y: var(--panel-pad-y, 24px);
  --c-pad-x: var(--panel-pad-x, 28px);
  --c-block-gap: var(--panel-block-gap, 24px);
  --c-row-gap: var(--panel-row-gap, 7px);
  --c-panel-bg: var(--panel-bg, rgba(255,255,255,0.012));
}

/* One shared panel instead of three separately-framed columns. */
.contact-panel {
  margin: 0 var(--page-pad-x, 40px);
  /*
   * Where it sits, measured from the TOP of the banner rather than from the
   * banner's bottom edge.
   *
   * Measured from the bottom, every change to the banner's height dragged
   * the panel along with it — you could not try a taller photo without
   * re-tuning the panel afterwards. Subtracting the banner's own height
   * here cancels that out: the panel lands at --panel-top from where the
   * banner starts, and the banner can be any height it likes underneath.
   *
   * Normal document flow already paints a later element over an earlier
   * one, so a negative result is enough to lay the panel over the photo
   * with no extra stacking rules — position/z-index just keep that
   * reliable across browsers.
   */
  margin-top: calc(var(--panel-top, 52vh) - var(--banner-height, 52vh));
  position: relative;
  z-index: 2;
  /* Framed or flat, from the CMS (Contact page → Banner & panel). */
  border: 0.5px solid var(--panel-border, var(--border));
  border-radius: 2px;
  background: var(--c-panel-bg, rgba(255,255,255,0.012));
}
@media (max-width: 900px) { .contact-panel { margin: 0; border-left: none; border-right: none; } }

.contact-grid {
  display: grid;
  /* Bookings, then About (which carries the most text, so it takes the
     roomiest column), then Gear — when all three are present. JS sets
     data-cols when a section is empty and the grid should read as 2 or 1
     columns instead; kept as a plain attribute rather than an inline
     style so it can never outrank the phone rule below. */
  grid-template-columns: var(--panel-columns, 1fr 1.15fr 1fr);
}
@media (min-width: 901px) {
  .contact-grid[data-cols="2"] { grid-template-columns: 1fr 1fr; }
  .contact-grid[data-cols="1"] { grid-template-columns: 1fr; }
}
@media (max-width: 900px) { .contact-grid { grid-template-columns: 1fr; } }

/* The panel's spacing comes from the CMS (Contact page → Panel). The values
   in the fallbacks are the original ones, so a page saved before those
   fields existed is laid out exactly as it always was. */
.contact-col { padding: var(--c-pad-y, 24px) var(--c-pad-x, 28px); border-right: 0.5px solid var(--border); }
.contact-col:last-child { border-right: none; }
@media (max-width: 900px) {
  .contact-col {
    border-right: none;
    border-bottom: 0.5px solid var(--border);
  }
  .contact-col:last-child { border-bottom: none; }
}

/* Gear's own groups flow into two columns instead of one long list — or one,
   when the CMS asks for it (Contact page → Gear → Gear columns). */
.gear-columns { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: repeat(2, auto); grid-auto-flow: column; column-gap: var(--gear-column-gap, 22px); }
.gear-columns[data-columns="1"] { grid-template-columns: 1fr; grid-template-rows: none; grid-auto-flow: row; }
/* A phone is never wide enough for two, whatever is set. 620px rather than
   500 so it matches the breakpoint everything else on this page uses — the
   gap mattered once the gear list could be shown on a phone at all. */
@media (max-width: 620px) { .gear-columns { grid-template-columns: 1fr; grid-template-rows: none; grid-auto-flow: row; } }

/* ---------- Contact on a phone ----------
   A different page, not a narrower one. The photo becomes a backdrop the
   details scroll over, instead of a tall block that pushes them off the
   bottom of the screen, and the page is cut back by default to what someone
   reaching for their phone actually wants: how to make contact, and the two
   downloads. Appearance → Phone can put the rest back.

   620px matches `PHONE` in app.js and the rest of the site's phone rules.
   The panel has stacked to one column since 900px, which is the right
   behaviour for a tablet and is left alone. */
@media (max-width: 620px) {
  /* The phone's own numbers, in place of the desktop's. This is the whole
     of what separates the two screens — every rule above reads these. */
  .page-contact {
    --c-pad-y: var(--phone-pad-y, 22px);
    --c-pad-x: var(--phone-pad-x, 20px);
    --c-block-gap: var(--phone-block-gap, 24px);
    --c-row-gap: var(--phone-row-gap, 7px);
    --c-panel-bg: var(--phone-panel-bg, rgba(0,0,0,0.7));
  }

  .page-contact .contact-banner {
    position: fixed;
    inset: 0;
    height: 100%;
    min-height: 0;
    max-height: none;
    border-bottom: none;
    z-index: 0;
  }
  /* The header sits over the fixed photo now rather than the page's own
     solid background, so it needs one of its own — otherwise the name and
     menu read straight off the picture with nothing behind them.
     .site-nav is shared markup beside <main>, not inside .page-contact, so
     it can't be reached by a descendant selector the way the rest of this
     block can — hence the class toggled in app.js's render(). */
  .site-nav--contact { background: var(--bg); }

  .page-contact .contact-panel { margin-top: 0; }

  /* What the phone carries, set from Appearance → Phone. */
  .page-contact[data-phone-about='off'] .about-copy { display: none; }
  .page-contact[data-phone-gear='off'] .col-gear { display: none; }
  /*
   * With the About copy hidden and no CV or gear list uploaded, that column
   * has nothing left but its own heading — a bare ABOUT sitting over empty
   * space. `has-downloads` is set in renderContact() when there is something
   * under it, so this only hides the genuinely empty case.
   */
  .page-contact[data-phone-about='off'] .col-about:not(.has-downloads) { display: none; }

  /*
   * The footer carries a 30-character email and a copyright line; side by
   * side they do not fit a phone's width and were wrapping into each other.
   * Stacked, and on the panel's own backing rather than bare on the photo,
   * which is where the rest of the page's text sits.
   */
  .page-contact .site-footer {
    position: relative;
    z-index: 2;
    flex-direction: column;
    align-items: flex-start;
    gap: 6px;
    background: var(--c-panel-bg, rgba(0,0,0,0.7));
    border-top: none;
    padding: 14px var(--c-pad-x, 20px) 18px;
  }
}

.col-label { font-size: 9px; letter-spacing: 0.24em; color: var(--fg-dim); margin-bottom: var(--panel-label-gap, 16px); }
.contact-col p { font-size: 13px; line-height: 1.85; color: var(--fg-mid); margin: 0 0 22px; }

.cv-btn {
  display: flex;
  justify-content: space-between;
  width: 100%;
  font-size: 11px;
  letter-spacing: 0.1em;
  color: var(--fg-mid);
  padding: 12px 0;
  background: none;
  border: none;
  cursor: pointer;
  transition: color 0.2s;
}
.cv-btn:hover { color: var(--fg); }
/* A hairline between stacked downloads so they read as separate items. */
.cv-btn + .cv-btn { border-top: 0.5px solid var(--border); }
.cv-arrow { opacity: 0.35; }

.contact-block { margin-bottom: var(--c-block-gap, 24px); }
.block-label { font-size: 9px; letter-spacing: 0.2em; color: var(--fg-dim); margin-bottom: 7px; }
.block-name { font-size: 13px; font-weight: 500; color: var(--fg); margin-bottom: 3px; }
.block-detail { font-size: 12px; color: var(--fg-mid); line-height: 1.6; }
.block-detail a { color: inherit; text-decoration: none; transition: color 0.2s; }
.block-detail a:hover { color: var(--fg); }

/* Instagram, under the contact details. Monochrome and set at the same
   brightness as the email above it by default, so it reads as another way to
   get in touch rather than as a logo dropped into the column. */
.contact-social {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  margin-top: 2px;
  font-size: 12px;
  color: var(--fg);
  text-decoration: none;
  opacity: var(--instagram-opacity, 0.58);
  transition: opacity 0.2s;
}
.contact-social:hover { opacity: 1; }
.ig-mark { width: 13px; height: 13px; flex: 0 0 auto; }

.gear-group { margin-bottom: calc(var(--c-block-gap, 24px) - 2px); }
.gear-label { font-size: 9px; letter-spacing: 0.2em; color: var(--fg-dim); margin-bottom: 9px; }
.gear-row { padding: var(--c-row-gap, 7px) 0; border-bottom: 0.5px solid var(--border); font-size: 12px; color: rgba(255,255,255,0.58); }
.gear-row.last { border-bottom: none; }
.gear-sub { display: block; font-size: 10px; color: var(--fg-dim); margin-top: 2px; }

/* Held while the first content request is in flight, so nothing flashes. */
body.loading #app { opacity: 0; }
#app { transition: opacity 0.25s ease; }

.load-error {
  position: fixed;
  inset: auto 0 0 0;
  padding: 14px var(--page-pad-x);
  background: rgba(60,20,20,0.9);
  color: rgba(255,255,255,0.9);
  font-size: 12px;
  letter-spacing: 0.08em;
  text-align: center;
}

/* A draft, previewed before publishing (loadPreview() in app.js) — unmistakable
   so it never reads as the real, live page. */
body.previewing::before {
  content: "PREVIEW — this project is not published yet";
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 50;
  padding: 8px var(--page-pad-x, 20px);
  background: #7a4a12;
  color: #fff;
  font-size: 11px;
  letter-spacing: 0.12em;
  text-align: center;
}
body.previewing #page-project { padding-top: 34px; }
.preview-status { padding: 60px var(--page-pad-x, 20px); color: var(--fg-mid); font-size: 13px; }

/* Downloads render as links now, so they need the button's own layout. */
a.cv-btn { text-decoration: none; }

/* ---------- Type driven by Appearance ----------
   Each role's settings arrive as custom properties (set in app.js from the
   Appearance panel). Fallbacks are the site's original values, so if a role
   is ever missing the design holds rather than collapsing. */

.hero-name {
  font-size: clamp(14px, 4.4vw, var(--t-heroName-size, 34px));
  font-weight: var(--t-heroName-weight, 300);
  letter-spacing: var(--t-heroName-tracking, 0.2em);
  line-height: var(--t-heroName-line, 1.2);
  color: var(--t-heroName-color, rgba(255,255,255,0.95));
  text-transform: var(--t-heroName-case, uppercase);
  font-style: var(--t-heroName-style, normal);
  text-decoration: var(--t-heroName-decoration, none);
}
.hero-role {
  font-size: var(--t-heroRole-size, 10px);
  font-weight: var(--t-heroRole-weight, 400);
  letter-spacing: var(--t-heroRole-tracking, 0.42em);
  line-height: var(--t-heroRole-line, 1.4);
  color: var(--t-heroRole-color, rgba(255,255,255,0.5));
  text-transform: var(--t-heroRole-case, uppercase);
  font-style: var(--t-heroRole-style, normal);
}
.brand-name {
  font-size: var(--t-name-size, 17px);
  font-weight: var(--t-name-weight, 300);
  letter-spacing: var(--t-name-tracking, 0.2em);
  line-height: var(--t-name-line, 1.2);
  color: var(--t-name-color, var(--fg));
  text-transform: var(--t-name-case, uppercase);
  font-style: var(--t-name-style, normal);
}
.brand-role {
  font-size: var(--t-roleLine-size, 10px);
  font-weight: var(--t-roleLine-weight, 400);
  letter-spacing: var(--t-roleLine-tracking, 0.3em);
  color: var(--t-roleLine-color, var(--fg-dim));
  text-transform: var(--t-roleLine-case, uppercase);
}
.nav-link {
  font-size: var(--t-menu-size, 10px);
  font-weight: var(--t-menu-weight, 400);
  letter-spacing: var(--t-menu-tracking, 0.16em);
  color: var(--t-menu-color, var(--fg-mid));
  text-transform: var(--t-menu-case, uppercase);
}
.tile-title {
  font-size: var(--t-projectTitle-size, 14px);
  font-weight: var(--t-projectTitle-weight, 500);
  letter-spacing: var(--t-projectTitle-tracking, 0.12em);
  line-height: var(--t-projectTitle-line, 1.3);
  color: var(--t-projectTitle-color, rgba(255,255,255,0.96));
  text-transform: var(--t-projectTitle-case, uppercase);
  font-style: var(--t-projectTitle-style, normal);
}
/* The role ("Dir.") and the name are styled separately, so either can be
   cased on its own — see .credit-role / .credit-name below. The line itself
   only carries what the two must share. */
.tile-credit {
  line-height: var(--t-projectCreditName-line, 1.4);
}
.tile-credit .credit-role {
  font-size: var(--t-projectCreditRole-size, 10px);
  font-weight: var(--t-projectCreditRole-weight, 400);
  letter-spacing: var(--t-projectCreditRole-tracking, 0.14em);
  color: var(--t-projectCreditRole-color, rgba(255,255,255,0.62));
  text-transform: var(--t-projectCreditRole-case, uppercase);
  font-style: var(--t-projectCreditRole-style, normal);
}
.tile-credit .credit-name {
  font-size: var(--t-projectCreditName-size, 10px);
  font-weight: var(--t-projectCreditName-weight, 400);
  letter-spacing: var(--t-projectCreditName-tracking, 0.14em);
  color: var(--t-projectCreditName-color, rgba(255,255,255,0.62));
  text-transform: var(--t-projectCreditName-case, uppercase);
  font-style: var(--t-projectCreditName-style, normal);
}
.detail-title {
  font-size: clamp(20px, 3.4vw, var(--t-pageTitle-size, 26px));
  font-weight: var(--t-pageTitle-weight, 400);
  letter-spacing: var(--t-pageTitle-tracking, 0.1em);
  line-height: var(--t-pageTitle-line, 1.2);
  color: var(--t-pageTitle-color, var(--fg));
  text-transform: var(--t-pageTitle-case, uppercase);
  font-style: var(--t-pageTitle-style, normal);
}
.detail-subline {
  font-size: var(--t-subLine-size, 11px);
  font-weight: var(--t-subLine-weight, 400);
  letter-spacing: var(--t-subLine-tracking, 0.18em);
  color: var(--t-subLine-color, var(--fg-dim));
  text-transform: var(--t-subLine-case, none);
}
.col-label, .stills-label, .gear-label, .block-label {
  font-size: var(--t-heading-size, 9px);
  font-weight: var(--t-heading-weight, 400);
  letter-spacing: var(--t-heading-tracking, 0.24em);
  color: var(--t-heading-color, var(--fg-dim));
  text-transform: var(--t-heading-case, uppercase);
}
.credit-role, .spec-key {
  font-size: var(--t-creditRole-size, 9px);
  font-weight: var(--t-creditRole-weight, 400);
  letter-spacing: var(--t-creditRole-tracking, 0.14em);
  color: var(--t-creditRole-color, var(--fg-dim));
  text-transform: var(--t-creditRole-case, uppercase);
}
.credit-name, .spec-val {
  font-size: var(--t-creditName-size, 12px);
  font-weight: var(--t-creditName-weight, 400);
  letter-spacing: var(--t-creditName-tracking, 0);
  color: var(--t-creditName-color, var(--fg-mid));
  text-transform: var(--t-creditName-case, none);
}
/* The hero credit keeps its emphasis regardless of the role's base weight. */
.credit-name.lead { color: var(--fg); font-weight: 500; }

.detail-copy, .contact-col p, .block-detail, .gear-row {
  font-size: var(--t-body-size, 13px);
  font-weight: var(--t-body-weight, 400);
  letter-spacing: var(--t-body-tracking, 0);
  line-height: var(--t-body-line, 1.8);
  color: var(--t-body-color, var(--fg-mid));
  text-transform: var(--t-body-case, none);
  font-style: var(--t-body-style, normal);
}
.site-footer {
  font-size: var(--t-footer-size, 10px);
  font-weight: var(--t-footer-weight, 400);
  letter-spacing: var(--t-footer-tracking, 0.14em);
  color: var(--t-footer-color, var(--fg-dim));
  text-transform: var(--t-footer-case, uppercase);
}


/* ---------- Phones ----------
   Placed last on purpose: the rules above map the CMS text settings onto the
   same selectors, so anything overriding them has to come after. */
@media (max-width: 620px) {
  /* Edge to edge by default: the page padding is what held thumbnails in, and
     on a phone it costs width the images could use. Set from the CMS. */
  .work-grid {
    padding-left: var(--mobile-pad-x, 0px);
    padding-right: var(--mobile-pad-x, 0px);
    padding-top: var(--mobile-pad-y, var(--page-pad-y));
  }
  .project-tile {
    width: var(--mobile-thumb-width, 100%);
    margin-bottom: var(--mobile-row-gap, var(--row-gap));
  }

  /* Type. Every one of these scales the size set under Text styles rather
     than replacing it, so the desktop setting still leads and the phone
     stays a proportion of it. */
  .brand-name { font-size: calc(17px * var(--mobile-brand-scale, 1)); }
  .brand-role { font-size: calc(10px * var(--mobile-brand-scale, 1)); }
  .nav-link { font-size: calc(var(--t-menu-size, 10px) * var(--mobile-menu-scale, 1)); }
  .credit-name, .spec-val { font-size: calc(12px * var(--mobile-body-scale, 1)); }
  .credit-role, .spec-key { font-size: calc(9px * var(--mobile-body-scale, 1)); }
  .detail-copy { font-size: calc(var(--t-body-size, 13px) * var(--mobile-body-scale, 1)); }

  /* Project page */
  .stills-viewport, .stills-strip img { height: var(--mobile-stills-height, 200px); }
  .stills-section { margin-top: calc(var(--section-gap) * var(--mobile-section-scale, 1)); }
  .stills-section, .credits-section, .specs-section {
    padding-top: calc(var(--section-pad) * var(--mobile-section-scale, 1));
  }

  /* Scaled from the CMS value rather than replaced by a fixed size, so the
     Appearance setting still drives it and the phone stays a shade smaller. */
  .tile-title {
    font-size: calc(var(--t-projectTitle-size, 14px) * var(--mobile-title-scale, 0.8));
    letter-spacing: calc(var(--t-projectTitle-tracking, 0.12em) * 0.85);
  }
  .tile-credit .credit-role {
    font-size: calc(var(--t-projectCreditRole-size, 10px) * var(--mobile-credit-scale, 0.85));
  }
  .tile-credit .credit-name {
    font-size: calc(var(--t-projectCreditName-size, 10px) * var(--mobile-credit-scale, 0.85));
  }
  .tile-caption.at-bottom { padding: 18px 14px 13px; }
  .tile-caption.at-top { padding: 13px 14px 18px; }
  .tile-caption.at-middle { padding: 14px; }
}
