/* Pumpkin House Interior Design
   Built against architecture/design-references/elicyon/motion-teardown.md,
   which is a frame-by-frame reading of the recording Bertrand supplied.

   Two motion layers. This file owns the entry sequence, the header, the
   cursor and the page-leave curtain — everything that has to work before a
   library downloads. site.js owns the scroll work on GSAP.

   The scroll layer was built on animation-timeline: view() first and moved.
   That feature is Chromium and very-recent-Safari only, so an older iPhone
   got a motionless page; and ScrollSmoother puts inertia on the scroll itself,
   which CSS cannot do at all.

   Every animation rule here sits inside a no-preference query, never outside
   it. A global reduce rule that zeroes animation-duration parks a filling
   animation on its last keyframe, which is how content disappears for the
   users least able to afford it.                                             */

@font-face {
  font-family: "Marcellus";
  src: url("../fonts/Marcellus.woff2") format("woff2");
  font-weight: 400;
  font-display: swap;
}
@font-face {
  font-family: "Instrument Sans";
  src: url("../fonts/InstrumentSans.woff2") format("woff2");
  font-weight: 400 600;
  font-display: swap;
}

:root {
  /* Grounds. --accent is the one token the whole identity turns on; David's
     own teal is parked beside it so the swap is a single edit, not a rebuild. */
  --ground: #f1f0eb;          /* sampled off the recording */

  /* His teal [Bertrand, 2026-08-10]. Sampled from the banquette in David's own
     restaurant fit-out, and chosen off a rendered board against seven others.
     It replaces Elicyon's periwinkle #6e89c8, which was their colour literally
     — and which put cream at 3.03:1, under the floor, on the three surfaces
     that use the accent as a ground.

     --on-accent exists because the accent flipped from light to dark and every
     one of those surfaces was carrying BLACK text. Black on the periwinkle was
     6.08:1; on the teal it is 1.92:1. The token makes the pairing explicit, so
     the next palette change is one edit and not six silent contrast failures. */
  --accent: #1e4244;
  --on-accent: #f1f0eb;                        /* 9.60:1 on the teal */
  --on-accent-72: rgba(241, 240, 235, 0.72);   /* 5.24:1, for small print */
  --taupe: #ccc5bb;
  --ink: #000;
  --ink-55: rgba(0, 0, 0, 0.55);
  /* --ink-30 is gone. It measured 2.09:1 on the cream and every one of its four
     uses was text or a control boundary, so the token could only ever produce a
     contrast failure. Keeping it around invites the next one.                 */
  --ink-14: rgba(0, 0, 0, 0.14);
  --reverse: #f1f0eb;
  --reverse-70: rgba(241, 240, 235, 0.7);

  /* 0.38, not their 0.10. Measured: at 0.10 David's photography leaves 13% of
     the headline area below AA. Their scrim is calibrated to their own lit,
     mid-toned, low-contrast photography. Logged as a dossier delta.          */
  --scrim: 0.38;

  --display: "Marcellus", Georgia, serif;
  --body: "Instrument Sans", system-ui, -apple-system, sans-serif;

  --gutter: 20px;
  --rhythm: 100px;
  --rhythm-lg: 160px;
  --content: 1240px;

  --t-body: 14px;
  --t-label: 12px;

  --ease: cubic-bezier(0.77, 0, 0.175, 1);
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
  --arrive: cubic-bezier(0.16, 1, 0.3, 1);
}

@media (min-width: 768px)  { :root { --gutter: 32px; } }
@media (min-width: 1200px) { :root { --gutter: 100px; } }
@media (max-width: 900px)  { :root { --rhythm-lg: 100px; } }

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

/* Uppercase, declared twice for the whole site instead of sixteen times.
   Two species share this property and nothing else — small labels, and
   display-scale headings — so each is declared once. One override follows
   later, .menu-meta a, which needs sentence case for a phone and an email;
   equal specificity, later in the file, so it still wins. */
.label, .ph, .hdr a, .hdr-menu, .ftr-links a, .closing-card .cta,
.statement-cta .cta, .chead-back, .pager .label {
  text-transform: uppercase;
}
.hdr-mark, .menu a, .hero-head, .band-word, .band-meta h3,
.svc-panel h3, .ftr-mark, .curtain span,
.chead h1, .pager-word {
  text-transform: uppercase;
}


body {
  margin: 0;
  background: var(--ground);
  color: var(--ink);
  font-family: var(--body);
  font-size: var(--t-body);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

/* Selection. Without this the browser falls back to the OS accent colour,
   which on Bertrand's machine is red and matched nothing on the page. The
   accent is already the identity token; cream on it measures 9.60:1.        */
::selection      { background: var(--accent); color: var(--on-accent); }
::-moz-selection { background: var(--accent); color: var(--on-accent); }

img { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; }

h1, h2, h3 {
  font-family: var(--display);
  font-weight: 400;              /* nothing anywhere on the reference is bold */
  margin: 0;
  letter-spacing: -0.01em;
}

.wrap { max-width: var(--content); margin: 0 auto; padding: 0 var(--gutter); }
.label {
  font-size: var(--t-label);
  letter-spacing: 0.1em;
  color: var(--ink-55);
  margin: 0;
}
.skip {
  position: absolute; left: -9999px;
  background: var(--ink); color: var(--reverse); padding: 1rem 1.5rem; z-index: 999;
}
.skip:focus { left: 1rem; top: 1rem; }

/* Anything David has not published yet. It has to be unmistakable on screen —
   a placeholder that reads as finished copy is how invented facts ship.      */
.ph {
  font-family: var(--body);
  font-size: var(--t-label);
  font-style: normal;
  letter-spacing: 0.06em;
  opacity: 0.75;
  border-bottom: 1px dashed currentColor;
  padding-bottom: 2px;
}

/* ================================================================ entry == */
/* A sheet in the accent with a rectangular hole clipped out of it. The hole
   grows; the photograph is already painted underneath and never moves, so
   LCP fires on the real hero while the viewer is still looking at colour.
   Growing a hole rather than crossfading two layers is IMA-22.              */

.enter {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 200;
  background: var(--accent);
}
/* His logo, centred, at a size that reads as a mark and not as a banner.
   Sized in vw with a ceiling so it is never larger than it is drawn.        */
.enter-sketch {
  display: none;
  position: fixed;
  top: 50%;
  left: 50%;
  z-index: 201;
  width: min(78vw, 460px);
  translate: -50% -50%;
  pointer-events: none;
}
.enter-sketch img { width: 100%; height: auto; }

/* The clip [Bertrand, 2026-08-11]. It is generated on pure black and laid onto
   the accent with `screen`, which is exact: screen(x, black) = x, so the ground
   vanishes and only the light line work survives. That is why there is no
   alpha-channel video here — true transparency would have meant a VP9-alpha
   WebM *and* an HEVC-alpha MP4 for Safari, two encodes of a thing one ordinary
   MP4 does.

   The accent background is load-bearing, not decorative. This element already
   carries `position: fixed` and a `z-index`, so it IS a stacking context, and
   `screen` therefore blends against THIS element rather than against the
   `.enter` sheet behind it. Removing the background was tried: the video
   blended against nothing and rendered its raw black, a 639px black slab in
   the middle of the teal.

   `padding-block` is load-bearing too. The video's height is fractional —
   639 / (1280/720) = 359.44px — so its bottom edge landed mid-pixel and left
   a one-pixel dark seam, measured at rgb(17,37,38) against the rgb(30,66,68)
   ground. Two pixels of accent padding put that fractional edge inside solid
   colour instead of on the box boundary.

   The logo occupies 72% of the clip's frame width, so the box is the mark's
   460px ceiling divided by 0.72 to land the mark at the same size the still
   was drawn at. */
html.has-clip .enter-sketch {
  width: min(108vw, 639px);
  padding-block: 2px;
  background: var(--accent);
  isolation: isolate;
}
html.has-clip .enter-sketch picture { display: none; }
.enter-sketch video {
  display: none;
  width: 100%;
  height: auto;
  mix-blend-mode: screen;
}
html.has-clip .enter-sketch video { display: block; }

html.is-entering { overflow: hidden; }
html.is-entering .enter { display: block; }
html.is-entering .enter-sketch { display: block; }
html.is-entering .hdr { opacity: 0; }

@media (prefers-reduced-motion: no-preference) {
  html.is-entering .enter {
    /* evenodd: two nested rings always cut a hole, whichever way they wind.
       Both polygons carry the same point count, so they interpolate.

       Opens at 4.10s, which is BEFORE the 4.04s clip has finished settling, on
       purpose: the photograph grows out of a mark that is already complete,
       so the two moments overlap instead of queueing. Waiting for the clip to
       end first added a beat of nothing and pushed the whole entry past five
       seconds.                                                              */
    animation: aperture 1s var(--arrive) 4.1s both;
  }
  /* The clip draws the house, then writes the lettering, then signs. The
     fallback below is the old wipe — one hard edge crossing a still — which
     only ever *implied* that order. It runs slower here (2.8s, was 1.8s)
     because with no clip to wait for it would otherwise finish early and hold
     on a static mark for over a second.                                     */
  html.is-entering .enter-sketch {
    animation: draw-on 2.8s cubic-bezier(0.65, 0, 0.2, 1) 0.6s both,
               fade-out 0.4s linear 4.3s both;
  }
  /* With the clip running, the wipe would fight it — the clip does its own
     drawing, so this element only needs the exit. */
  html.has-clip.is-entering .enter-sketch {
    animation: fade-out 0.4s linear 4.3s both;
  }
  html.entry-first .hdr { animation: fade-in 0.5s linear 4.6s both; }

  @keyframes draw-on {
    from { clip-path: inset(0 100% 0 0); }
    to   { clip-path: inset(0 0 0 0); }
  }
  @keyframes fade-out { to { opacity: 0; } }

  /* Three stops, not two. With only from/to the animation's backwards fill
     paints the seed-sized hole for the whole 1.85s delay, so the photograph
     was sitting there in a little window while the letters were still being
     drawn. Starting from a zero-area hole at dead centre keeps the sheet
     solid until the moment it is meant to open. */
  @keyframes aperture {
    0% {
      clip-path: polygon(evenodd,
        0 0, 100% 0, 100% 100%, 0 100%, 0 0,
        50% 50%, 50% 50%, 50% 50%, 50% 50%, 50% 50%);
    }
    26% {
      clip-path: polygon(evenodd,
        0 0, 100% 0, 100% 100%, 0 100%, 0 0,
        46.4% 46.1%, 53.6% 46.1%, 53.6% 53.9%, 46.4% 53.9%, 46.4% 46.1%);
    }
    100% {
      clip-path: polygon(evenodd,
        0 0, 100% 0, 100% 100%, 0 100%, 0 0,
        0 0, 100% 0, 100% 100%, 0 100%, 0 0);
    }
  }
  @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
}

/* =============================================================== header == */

.hdr {
  position: fixed;
  inset: 0 0 auto;
  z-index: 99;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  padding: 26px var(--gutter);
  color: var(--reverse);
  transition: transform 0.5s var(--ease),
              background-color 0.4s var(--ease-out),
              color 0.4s var(--ease-out);
}
.hdr[data-parked="true"] { transform: translateY(-100%); }
.hdr[data-solid="true"]  { background: var(--ground); color: var(--ink); }

/* A guarantee, not a decoration [Bertrand, 2026-08-11].
   The wordmark is cream over whatever photograph the page opens on, with nothing
   behind it. Measured behind the glyphs on the homepage: 5.8:1 at 1440x900, but
   at 1280x577 `object-fit: cover` reframes the hero and drops the mark onto the
   bright glassware of the wine rack. Same markup, same colour, legible at one
   window size and gone at another — which is why the report was "not really"
   rather than "never", and why the inner pages, which open on darker frames,
   never showed it.

   A gradient rather than a bar: it is invisible where the photograph is already
   dark and only does work where it is not. z-index -1 keeps it behind the header's
   own text inside the header's stacking context, so it darkens the photograph and
   never the wordmark. It retires the moment the cream header takes over, on the
   same 0.4s as the colour change, so the two never stack.

   work.html is excluded: `data-hdr="ink"` sets the mark near-black over a cream
   index page, where a dark wash would be the defect rather than the fix.        */
.hdr::before {
  content: "";
  position: absolute;
  inset: -26px 0 auto;
  height: 150px;
  z-index: -1;
  pointer-events: none;
  background: linear-gradient(to bottom, rgb(0 0 0 / 0.38), rgb(0 0 0 / 0));
  transition: opacity 0.4s var(--ease-out);
}
.hdr[data-solid="true"]::before,
body[data-hdr="ink"] .hdr::before,
html.menu-open .hdr::before { opacity: 0; }

.hdr-mark {
  grid-column: 2;
  justify-self: center;
  font-family: var(--display);
  font-size: 20px;
  letter-spacing: 0.2em;
  white-space: nowrap;
}
.hdr-nav { grid-column: 1; display: flex; gap: 2rem; }
.hdr-cta { grid-column: 3; justify-self: end; }
.hdr a {
  font-size: var(--t-body);
  line-height: 1;
  letter-spacing: 0.15em;
}
.hdr-nav a { position: relative; padding-bottom: 3px; }
.hdr-nav a::after {
  content: "";
  position: absolute;
  inset: auto 0 0 0;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.45s var(--ease);
}
.hdr-nav a:hover::after { transform: scaleX(1); }

/* Below 760 the inline nav is hidden, and until now nothing replaced it — on a
   phone the only route to Work or Studio was the footer. Three links do not
   justify a drawer, so this is the whole menu: one button, one periwinkle
   sheet, the links at display size. */
.hdr-menu {
  display: none;
  grid-column: 3;
  justify-self: end;
  padding: 0;
  border: 0;
  background: none;
  color: inherit;
  font-family: var(--body);
  font-size: var(--t-body);
  line-height: 1;
  letter-spacing: 0.15em;
  /* The button box was the 14px text and nothing else — a 50×14 target for
     the only mobile navigation control. The padding buys a ~44px hit area;
     the negative margin cancels it visually so the header grid reads the
     same as before. */
  padding: 15px 12px;
  margin: -15px -12px;
}

@media (max-width: 760px) {
  .hdr-nav, .hdr-cta { display: none; }
  .hdr-menu { display: block; }
  .hdr-mark { font-size: 15px; }
}

.menu {
  position: fixed;
  inset: 0;
  z-index: 150;
  display: grid;
  align-content: center;
  justify-items: center;
  gap: 0.6rem;
  background: var(--accent);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.42s var(--ease), visibility 0.42s;
}
.menu a {
  font-family: var(--display);
  font-size: clamp(34px, 12vw, 64px);
  line-height: 1.1;
  letter-spacing: 0.02em;
  color: var(--on-accent);
}
.menu-meta {
  margin-top: 2.5rem;
  font-size: var(--t-label);
  letter-spacing: 0.1em;
  text-align: center;
  color: var(--on-accent-72);
}
.menu-meta a { font: inherit; letter-spacing: inherit; text-transform: none; }

html.menu-open { overflow: hidden; }
html.menu-open .menu { opacity: 1; visibility: visible; }
/* Above the sheet, so the button that closes it stays reachable. */
html.menu-open .hdr { z-index: 160; color: var(--on-accent); }
html.menu-open .hdr[data-parked="true"] { transform: none; }

/* ================================================================= hero == */

.hero { position: relative; height: 100svh; overflow: hidden; }
.hero picture, .hero img { position: absolute; inset: 0; }
.hero img { width: 100%; height: 100%; object-fit: cover; }
.hero::after {
  content: "";
  position: absolute;
  inset: 0;
  background: rgb(0 0 0 / var(--scrim));
}

/* A staircase, not a stack. Measured off the recording: the three lines start
   at roughly 5%, 38% and 8% of viewport width.                              */
.hero-head {
  position: relative;
  z-index: 2;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 0.06em;
  margin: 0;
  padding: 0 var(--gutter);
  font-family: var(--display);
  font-weight: 400;
  /* Sized off Marcellus's real advance widths, not guessed. The longest line,
     ARCHITECTURAL, measures 7.738em; the column is 100vw minus two gutters.
     10.8vw is the largest size that still leaves that word clear of the edge
     at every width from 320 up. Their longest word is eight characters, so
     this is as big as David's own vocabulary allows — the constraint is the
     word, not the taste. */
  font-size: min(10.8vw, 196px);
  line-height: 0.98;
  letter-spacing: 0;
  color: var(--reverse);
}
.hero-head span { display: block; }
.hero-head span:nth-child(2) { margin-left: 22%; }
/* 0 / 22 / 8, not 0 / 40 / 0. The single 40% jog on EXTERIOR was the
   disharmony in the outside critique: one word leaping with nothing
   answering it. Picked off a five-way rendered board [Bertrand, 2026-08-26,
   variant A]. */
.hero-head span:nth-child(3) { margin-left: 8%; }

@media (max-width: 620px) {
  .hero-head span:nth-child(2) { margin-left: 11%; }
  .hero-head span:nth-child(3) { margin-left: 4%; }
}

@media (prefers-reduced-motion: no-preference) {
  /* Keyed to .entry-first, which is stamped once and never removed — NOT to
     .is-entering, which comes off at 5150ms to release the sheet while the
     headline is still mid-rise. When the delays hung off .is-entering, that
     removal took the override with it: each span re-resolved against its base
     delay with 2950ms already banked, and fill: both slammed all three onto
     their final keyframe. Word one snapped 150ms into its rise; words two and
     three popped in from nothing. Separating "the sheet is up" from "this is
     a first visit" is the fix. */
  .hero-head span { animation: rise 1.15s var(--arrive) both; }
  .hero-head span:nth-child(2) { animation-delay: 0.22s; }
  .hero-head span:nth-child(3) { animation-delay: 0.44s; }
  /* Shifted +0.70s with the clip [Bertrand, 2026-08-11]. These three numbers,
     the aperture delay, the header fade and the setTimeout in index.html are
     one clock — change any of them and change all of them. */
  html.entry-first .hero-head span { animation-delay: 4.90s; }
  html.entry-first .hero-head span:nth-child(2) { animation-delay: 5.12s; }
  html.entry-first .hero-head span:nth-child(3) { animation-delay: 5.34s; }
  @keyframes rise {
    from { opacity: 0; transform: translateY(0.38em); }
    to   { opacity: 1; transform: none; }
  }
}

/* ============================================================ statement == */
/* Two independent mechanisms sharing one section: a reading band that darkens
   whichever words sit at the vertical middle, and image cards on both edges
   travelling faster than the type and crossing over it.                     */

/* Two full viewports of scroll. The first build gave this 856px and the
   section read as a paragraph with some pictures near it: the cards were
   sliced by the section boundary and the reading band had no distance to
   sweep across. The height IS the effect — the copy sits still in the middle
   while the images travel the whole way past it. */
.statement {
  position: relative;
  min-height: 200svh;
  display: grid;
  align-content: center;
  padding: var(--rhythm-lg) 0;
  overflow: hidden;                /* the flankers bleed; the page must not  */
}

/* 200svh is 1688px on a 844px phone, against a paragraph about 273px tall —
   two near-empty screens per sentence, which reads as the page having stalled
   rather than as space. The height still has to be generous enough for the
   cards to travel and the reading band to sweep, so it is cut rather than
   removed. Cutting it is also what makes room for the slower scroll: less
   distance covered more deliberately, instead of more distance either way. */
@media (max-width: 620px) {
  .statement { min-height: 148svh; }
}
.statement-copy {
  position: relative;
  z-index: 2;
  max-width: 19ch;
  margin: 0 auto;
  padding: 0 var(--gutter);
  font-family: var(--display);
  font-size: clamp(30px, 4.4vw, 68px);
  line-height: 1.3;
  letter-spacing: -0.005em;
  text-align: center;
  text-wrap: balance;
  pointer-events: none;            /* cards behind stay hoverable           */
}
.statement-copy w { display: inline-block; color: var(--ink); }

.statement-attr {
  position: relative;
  z-index: 2;
  margin: 3.5rem 0 0;
  text-align: center;
}

/* Sits above the flankers like the copy does; the card-overlap sweep treats
   it the same as a word — nothing decorative may pass over it. */
.statement-cta {
  position: relative;
  z-index: 2;
  margin: 2.5rem 0 0;
  text-align: center;
}

/* Behind the type, not in front of it.
   The first pass put the cards above the words because the crossing is the
   effect. Rendered, a black-and-white card landed on the D of DRAWN and the
   line read "RAWN". The cards still cross — you watch them slide behind the
   sentence — but a decorative image is never allowed to eat a letter. */
.flankers {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
}
/* Eight cards, alternating sides, no two at the same height, sizes irregular.
   The four sitting between 38% and 62% are the ones that matter — they are
   the ones that cross the type. Cards that politely clear the paragraph read
   as a gallery beside some words; cards that pass over it read as the room
   moving around you, which is the thing being copied. */
.flankers img {
  position: absolute;
  height: auto;
  will-change: transform;
}
/* Sizes run roughly a quarter larger than the first pass and spread wider,
   because a set of near-identical rectangles reads as a grid that happens to
   be scattered. A degree or two of rotation on some of them, never all, does
   more for that than any amount of repositioning. */
/* The card is selected through its <picture>, never as `img:nth-child(N)`.
   Round six wrapped each photograph in a <picture> to give it AVIF/WebP rungs,
   and a <picture> holds two <source> tags before its <img>. Every image was
   therefore :nth-child(3) OF ITS OWN WRAPPER, so rule 3 won on all ten and the
   other nine matched nothing: ten cards stacked at left 7% / top 21% / rotate
   2deg, measured identical to the pixel. Counting children only ever worked
   while the images were bare siblings, and nothing said so out loud.

   `left` AND `right` are both stated on every card, one of them `auto`. An
   absolutely positioned box with a definite width and both offsets set is
   over-constrained, and `left` silently wins — which is exactly how the first
   attempt at this fix put all ten back on the left-hand side again.          */
/* Eight cards since round eight, and none of them bleeds off-frame any more:
   the outside critique asked for bigger images with padding on the sides, so
   the negative offsets became 2-4% insets and every clamp grew ~a third. The
   x-budget still holds: inset-px plus width at 1440 stays under ~360px, the
   measured start of the 19ch column (see the old card-7 note: 394 vs 370 was
   the one card that reached the words). Widths cap at 360px so wider screens
   gain margin, not collisions. */
/* Ten since round nine. Insets vary 2-6% deliberately: round-eight verification
   caught same-side neighbours at near-equal inset+width nesting inside each
   other's horizontal span, leaving photo slivers either side. Staggered insets
   keep neighbours offset even when travel brings them level. */
.flankers picture:nth-child(1)  img { left: 3%;   right: auto; top: 2%;  width: clamp(160px, 19vw, 300px); rotate: -1.5deg; }
.flankers picture:nth-child(2)  img { left: auto; right: 2%;   top: 9%;  width: clamp(200px, 22vw, 350px); }
.flankers picture:nth-child(3)  img { left: 6%;   right: auto; top: 19%; width: clamp(140px, 16vw, 260px); rotate: 2deg; }
.flankers picture:nth-child(4)  img { left: auto; right: 3%;   top: 28%; width: clamp(180px, 20vw, 330px); }
.flankers picture:nth-child(5)  img { left: 2%;   right: auto; top: 37%; width: clamp(190px, 22vw, 350px); rotate: -1deg; }
.flankers picture:nth-child(6)  img { left: auto; right: 2.5%; top: 46%; width: clamp(185px, 21.5vw, 350px); }
.flankers picture:nth-child(7)  img { left: 5%;   right: auto; top: 56%; width: clamp(160px, 19.5vw, 310px); rotate: 1.5deg; }
.flankers picture:nth-child(8)  img { left: auto; right: 5%;   top: 64%; width: clamp(190px, 19vw, 300px); }
.flankers picture:nth-child(9)  img { left: 3%;   right: auto; top: 72%; width: clamp(170px, 20vw, 320px); rotate: -2deg; }
.flankers picture:nth-child(10) img { left: auto; right: 2%;   top: 82%; width: clamp(185px, 21.5vw, 350px); }

/* The previous attempt at this moved the middle four sideways. Measured at
   390px it did not work and could not have: every card clamps to its minimum
   width there, the centred copy runs the full 350px column, and pushing a
   146px card to left:-12% still leaves 99px of it lying under the sentence —
   less clearance, in fact, than the four cards the rule did not touch. There
   is no horizontal room on a phone for a card and a line of 30px type.

   So on a phone they move vertically instead. The copy is centred, so the
   section's top and bottom thirds are empty; all the cards go there and the
   middle band is left to the words. The device survives — cards still bleed
   off both edges, still travel at their own speeds, still cross the section —
   they simply stop crossing the one thing that has to stay readable.

   Measured, so the bands are not guesswork: the copy occupies 39.1%–60.9% of
   the section. Cards are ordered tallest-first into the top band so the tall
   ones have the most room below them, and every card clears the copy with
   margin left over on a short phone, where the section shrinks but the cards
   do not and every height percentage grows.                                 */
@media (max-width: 620px) {
  /* Selected through <picture>, for the reason written above the desktop block.
     This override was dead for the same cause, which made a phone the WORSE of
     the two: all ten cards piled at x25/y1055, measured. */
  .flankers picture:nth-child(10) img { top: 2%;  left: auto; right: -8%;  }  /* tallest, so first */
  .flankers picture:nth-child(6)  img { top: 6%;  left: -8%;  right: auto; }
  .flankers picture:nth-child(4)  img { top: 12%; left: auto; right: -6%;  }
  .flankers picture:nth-child(2)  img { top: 24%; left: -6%;  right: auto; }  /* landscape, short */
  .flankers picture:nth-child(8)  img { top: 22%; left: auto; right: -10%; }  /* landscape, short */
  .flankers picture:nth-child(9)  img { top: 66%; left: -7%;  right: auto; }
  .flankers picture:nth-child(1)  img { top: 72%; left: auto; right: -6%;  }
  .flankers picture:nth-child(7)  img { top: 78%; left: -5%;  right: auto; }
  .flankers picture:nth-child(3)  img { top: 85%; left: auto; right: -7%;  }
  .flankers picture:nth-child(5)  img { top: 91%; left: -9%;  right: auto; }  /* landscape, short */
}

/* The sweep and the drift are ScrollTrigger's, in site.js. They were built
   twice: first on animation-timeline: view(), then moved, because that
   feature is Chromium-and-very-recent-Safari only — on an older iPhone the
   whole page arrived motionless. The base styles below are the resting state,
   so if the script never loads the section is still correct, just still. */

/* ================================================================ band == */
/* Full-bleed photograph with an enormous tracked word travelling across it.  */

.band { position: relative; height: 100svh; overflow: hidden; }
.band picture, .band img { position: absolute; inset: 0; }
.band img { width: 100%; height: 100%; object-fit: cover; }
.band::after {
  content: "";
  position: absolute;
  inset: 0;
  background: rgb(0 0 0 / 0.3);
}
.band-word {
  position: absolute;
  top: 50%;
  left: 50%;
  z-index: 2;
  margin: 0;
  translate: -50% -50%;
  font-family: var(--display);
  font-weight: 400;
  /* 9.5vw is a ceiling, not a size. It changes nothing at any real desktop
     aspect — it binds only below 1.58:1, where 15vh alone overflowed: at
     768x1024 the old rule computed 153.6px and set COMMERCIAL 1302px wide
     inside a 768px box. 1440x900, 1280x800 and 1920x1080 are untouched.     */
  font-size: min(15vh, 190px, 9.5vw);
  line-height: 1;
  letter-spacing: 0.18em;
  white-space: nowrap;
  color: var(--reverse);
  will-change: transform;
}

/* Two interleaved staggered rows below 900.
   Measured, not estimated: at 390px the old clamp resolved to 126.6px and set
   HOSPITALITY 1008px wide inside a 350px column that is overflow:hidden — 658
   of those pixels clipped, leaving about five letters on screen. COMMERCIAL
   was 1074px. One line could only fit at about 44px, which is not a display
   device any more, so the word breaks instead: the same answer the reference
   uses for its own long words (motion-teardown §4).

   The spans are inline above this breakpoint, so HOSPI + TALITY concatenate
   and the wide layout is exactly what it was.
   PAIRED WITH site.js: innerWidth < 900. Change one, change the other.      */
@media (max-width: 899px) {
  .band-word {
    display: grid;
    justify-items: start;
    /* 16.5vw, down from 18.5: round eight moved the splits to syllable
       boundaries, and the long rows grew from six glyphs (TALITY) to seven
       (MERCIAL, DENTIAL). Measured at the old size DENTIAL ran 9% past the
       right edge; 16.5vw seats it with the overhang intact. */
    font-size: min(16.5vw, 22vh);
    line-height: 0.84;              /* 0.84 less the 0.7002 cap = 0.14em apart */
  }
  /* -0.18em kills the trailing letter-space CSS adds after the last glyph, so
     the rows align on ink rather than on empty tracking. -0.35em is the
     stagger itself: each row overhangs the opposite edge, which is the only
     thing that offsets COMMERCIAL's two near-identical halves against each
     other — without it that word reads as a justified block, not a device.  */
  .band-word span { margin-right: -0.18em; }
  .band-word span:first-child { justify-self: start; margin-left: -0.35em; }
  .band-word span:last-child  { justify-self: end;   margin-right: calc(-0.18em - 0.35em); }
}

.band-meta {
  position: absolute;
  right: var(--gutter);
  bottom: calc(var(--gutter) + 12px);
  z-index: 2;
  text-align: right;
  color: var(--reverse);
}
/* Full cream, not 70%. Measured over the real photographs rather than over a
   flat swatch: at 0.7 alpha this 12px line fell to 2.93:1 on the hospitality
   band at 1440 and 3.85:1 on residential at 390 — under the 4.5:1 floor for
   small text, and failing on the median glyph rather than on a bright tail.
   The h3 beside it, at full opacity, passed everywhere. The alpha was the
   whole difference, so it goes.                                             */
.band-meta .label { color: var(--reverse); }
.band-meta h3 {
  font-size: clamp(19px, 2vw, 27px);
  letter-spacing: 0.04em;
  margin-top: 0.4rem;
}
.band-meta a { display: inline-block; }
.band-meta a:hover h3 { opacity: 0.72; }

/* The travel is ScrollTrigger's — see site.js. */

/* ============================================================= services == */

.services { padding: var(--rhythm-lg) 0; }
.services-head { text-align: center; margin-bottom: var(--rhythm); }
.services-head h2 {
  font-size: clamp(30px, 4.4vw, 62px);
  line-height: 1.12;
}
.svc {
  display: grid;
  grid-template-columns: 1fr 1fr;
  align-items: stretch;
  min-height: 78svh;
  border-top: 1px solid var(--ink-14);
}
.svc-shot { position: relative; overflow: hidden; }
.svc-shot img { width: 100%; height: 100%; object-fit: cover; }
.svc-panel {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 1.5rem;
  padding: var(--rhythm) clamp(24px, 5vw, 76px);
}
.svc-panel h3 {
  font-size: clamp(24px, 2.8vw, 40px);
  letter-spacing: 0.03em;
}
.svc-panel p { margin: 0; max-width: 42ch; line-height: 1.7; color: var(--ink-55); }
.svc:nth-of-type(even) .svc-shot { order: 2; }

@media (max-width: 860px) {
  .svc { grid-template-columns: 1fr; min-height: 0; }
  .svc-shot { height: 58svh; }
  .svc:nth-of-type(even) .svc-shot { order: 0; }
}

/* The swell is ScrollTrigger's — see site.js. */

/* ============================================================== closing == */

.closing {
  position: relative;
  min-height: 100svh;
  display: grid;
  place-items: center;
  overflow: hidden;
  padding: var(--rhythm) var(--gutter);
}
.closing > picture, .closing > picture img { position: absolute; inset: 0; }
.closing > picture img { width: 100%; height: 100%; object-fit: cover; }
.closing::after {
  content: "";
  position: absolute;
  inset: 0;
  background: rgb(0 0 0 / 0.22);
}
.closing-card {
  position: relative;
  z-index: 2;
  width: min(460px, 100%);
  background: var(--ground);
  padding: clamp(30px, 4vw, 52px);
  text-align: center;
}
.closing-card h2 { font-size: clamp(25px, 2.9vw, 38px); line-height: 1.18; }
.closing-card p { margin: 1.25rem 0 0; color: var(--ink-55); line-height: 1.7; }
.closing-card a[href^="mailto"], .closing-card a[href^="tel"] {
  border-bottom: 1px solid var(--ink-14);
}
.closing-card a[href^="mailto"]:hover, .closing-card a[href^="tel"]:hover {
  border-bottom-color: currentColor;
}
/* His commercial position. Deliberately small and deliberately last: it is a
   fact about how he charges, and the first thing a visitor read for one whole
   round was this sentence blown up to 68px as though it were a manifesto. */
.terms {
  margin-top: 1.75rem !important;
  padding-top: 1.25rem;
  border-top: 1px solid var(--ink-14);
  font-size: var(--t-label);
  line-height: 1.65;
  letter-spacing: 0.01em;
  color: var(--ink-55);
}
.closing-card .cta,
.statement-cta .cta {
  display: inline-block;
  margin-top: 2rem;
  padding-bottom: 4px;
  border-bottom: 1px solid var(--ink);
  font-size: var(--t-label);
  letter-spacing: 0.16em;
}
.statement-cta .cta { margin-top: 0; }

/* =============================================================== footer == */
/* Their wordmark is one line, black, on periwinkle, and it reaches both edges.
   "PUMPKIN HOUSE" is 13 characters against their 7, so at Marcellus's measured
   advance widths the size that fills the viewport is ~12.2vw — a hair short of
   the edge by choice, because a wordmark that clips is worse than one that
   doesn't quite touch.                                                       */

.ftr { background: var(--accent); color: var(--on-accent); padding-top: var(--rhythm); }
.ftr-top {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 2.5rem;
  align-items: start;
  padding: 0 var(--gutter) var(--rhythm);
}
.ftr-top h2 { font-size: clamp(23px, 2.6vw, 34px); line-height: 1.2; }
.ftr-top p { margin: 1rem 0 0; max-width: 34ch; line-height: 1.65; }
.ftr-links { display: flex; gap: clamp(2rem, 6vw, 5rem); }
.ftr-links ul { margin: 0; padding: 0; list-style: none; display: grid; gap: 0.55rem; }
.ftr-links a {
  font-size: var(--t-label);
  letter-spacing: 0.1em;
}
.ftr-links a:hover { opacity: 0.65; }

.ftr-mark {
  display: block;
  margin: 0;
  font-family: var(--display);
  font-size: 12.2vw;
  line-height: 0.82;
  letter-spacing: -0.005em;
  text-align: center;
  white-space: nowrap;
}
.colophon {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 1rem;
  padding: 1.5rem var(--gutter) 1.75rem;
  font-size: var(--t-label);
  color: var(--on-accent-72);
}

@media (max-width: 760px) {
  .ftr-top { grid-template-columns: 1fr; }
}

.ftr-mark { transform-origin: 50% 100%; }
/* The arrival is ScrollTrigger's — see site.js. */

/* =============================================================== cursor == */

/* A dot, and only a dot.

   The triangle before it had a real bug, not a styling problem: colour was
   chosen by a single data-over value whose ternary tested links BEFORE dark
   sections, so every link sitting on a photograph — the whole header nav, all
   three band links, every closing CTA and phone number — flipped the cursor
   from cream to black at the exact moment it was hovered. That is what looked
   like a glitch on WORK and STUDIO.

   mix-blend-mode: difference deletes the problem rather than patching it. A
   cream dot inverts against whatever is under it, so it cannot be invisible
   on any ground the site has: photograph, cream, or periwinkle. No colour
   logic left to get wrong.

   Two elements, because position and size must not fight: the wrapper carries
   the translate that gsap.quickTo writes every frame, the inner dot carries
   the scale. Scaling the wrapper would be overwritten sixty times a second;
   animating width and height would relayout instead of compositing. */

.cursor {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 300;
  pointer-events: none;
  opacity: 0;
  mix-blend-mode: difference;
  will-change: transform;
  transition: opacity 0.35s var(--ease-out);
}
.cursor i {
  display: block;
  width: 13px;
  height: 13px;
  margin: -6.5px 0 0 -6.5px;
  border-radius: 50%;
  background: var(--reverse);
  transition: transform 0.42s var(--ease-out);
}
html.has-cursor .cursor { opacity: 1; }
html.has-cursor .cursor[data-hot="1"] i { transform: scale(3.4); }
html.has-cursor * { cursor: none; }

@media (pointer: coarse), (prefers-reduced-motion: reduce) {
  .cursor { display: none; }
  html.has-cursor * { cursor: auto; }
}

/* =============================================================== curtain == */
/* Internal navigation is covered rather than flashed. The curtain is inert
   until JS arms it, so a no-JS visit just follows the link.                  */

.curtain {
  position: fixed;
  inset: 0;
  z-index: 250;
  display: grid;
  place-items: center;
  background: var(--accent);
  transform: translateY(100%);
  pointer-events: none;
}
.curtain span {
  font-family: var(--display);
  font-size: clamp(28px, 5vw, 68px);
  letter-spacing: 0.16em;
  color: var(--reverse);
  opacity: 0;
}

@media (prefers-reduced-motion: no-preference) {
  html.is-leaving .curtain { animation: curtain-down 0.66s var(--ease) both; }
  html.is-leaving .curtain span { animation: fade-in 0.4s linear 0.22s both; }
  @keyframes curtain-down {
    from { transform: translateY(100%); }
    to   { transform: translateY(0); }
  }
}

/* ============================================================ reduce == */
/* Deliberately narrow. It does not zero animation-duration globally: with
   fill: both that parks every filling animation on its final keyframe. Each
   motion block above is already gated on no-preference, so under reduce the
   rules never apply and the base styles stand on their own.                  */

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  .hdr { transition: none; }
}

/* ========================================================================= */
/* PAGE SURFACES                                                             */
/* Appended 2026-08-09. Everything above this line belongs to index.html and  */
/* is untouched — nothing below alters an existing rule.                      */
/*                                                                            */
/* The six inner pages are built out of the homepage's own species rather      */
/* than new ones: .hero for a full-bleed opener, .closing + .closing-card for  */
/* a card floating over a photograph, .svc for a half-photograph split, and    */
/* .label / .ph / .wrap / .ftr verbatim. Only what those cannot do is added    */
/* here. Two consequences worth knowing before editing:                        */
/*                                                                            */
/*  - Naming an opener .hero is load-bearing, not decorative. site.js keys the */
/*    header's parked/solid logic and the opener parallax off ".hero" being on */
/*    the page, so a differently-named opener would leave the header behaving  */
/*    as if the page had no first section.                                     */
/*  - Every page carries a .closing-card for the same reason: site.js runs a   */
/*    plain gsap.from(".closing-card"), which logs a missing-target warning on  */
/*    any page that lacks one.                                                 */
/*                                                                            */
/* Uppercase is taken from .label in the markup rather than re-declared here,  */
/* which keeps the eyebrow count flat against the pre-flight ceiling.          */
/* ========================================================================= */

/* ------------------------------------------------------ header on cream -- */
/* work.html opens on the cream ground, where a reversed header is invisible
   until the first scroll flips it solid. One attribute on <body> changes the
   resting colour; the solid state stays the shared rule.                     */

body[data-hdr="ink"] .hdr { color: var(--ink); }

/* --------------------------------------------------- full-bleed opener --- */
/* Worn together with .hero. The title is set at .band-word's face and its
   exact 0.18em tracking, so the homepage band a visitor clicked becomes the
   page they land on — the word does not change size, colour or spacing
   across the navigation, only position.                                     */

/* .hero fixes the opener at exactly 100svh, which is right until a short
   landscape phone leaves the title block taller than the viewport and
   .hero's own overflow:hidden cuts the bottom off it. Grid plus a minimum
   lets the section grow instead of clipping; the photograph is absolutely
   positioned to inset 0, so it grows with it.                              */
.chead {
  height: auto;
  min-height: 100svh;
  display: grid;
}
.chead-inner {
  position: relative;
  z-index: 2;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  align-items: flex-start;
  gap: 1rem;
  padding: calc(var(--gutter) + 92px) var(--gutter) clamp(44px, 8.5vh, 104px);
  color: var(--reverse);
  /* The flat 0.38 scrim .hero carries is calibrated for a headline sitting in
     the middle of the frame. This title sits on the floor of it, where David's
     photographs are at their brightest — a lit counter, a pool, a tiled floor.
     A bottom-weighted wash puts the density where the type is and leaves the
     top of the picture alone, which is the half worth looking at.           */
  background: linear-gradient(
    to top,
    rgb(0 0 0 / 0.5) 0%,
    rgb(0 0 0 / 0.28) 34%,
    rgb(0 0 0 / 0) 68%);
}
.chead-inner .label { color: var(--reverse-70); }
/* Margins are zero throughout: the flex gap above is the only spacing
   mechanism in this block, so nothing can compound with it.               */
.chead-inner h1 {
  /* Sized off Marcellus's measured advance widths, not guessed. COMMERCIAL is
     the longest single word at 6.680em; ten letters of 0.18em tracking add
     1.80em, for 8.48em total. The binding case is 1200px, where --gutter
     steps to 100px and the column drops to 1000px: 9.4vw lands at 112.8px
     against a 117.9px ceiling. Every other width has more room than that.   */
  margin: 0;
  font-size: min(9.4vw, 150px);
  line-height: 0.94;
  letter-spacing: 0.18em;
}
.chead-sub {
  margin: 0;
  max-width: 34em;
  font-size: clamp(15px, 1.15vw, 17px);
  line-height: 1.65;
  text-wrap: balance;          /* two even lines beat one line and an orphan */
  color: var(--reverse);
}
.chead-back { margin: 0; }
.chead-back a { border-bottom: 1px solid var(--reverse-70); padding-bottom: 3px; }
.chead-back a:hover { border-bottom-color: var(--reverse); }

@media (max-width: 620px) {
  /* Margins are zero throughout: the flex gap above is the only spacing
   mechanism in this block, so nothing can compound with it.               */
.chead-inner h1 { line-height: 1.02; }
}

/* The overlay badge that used to sit on every render was removed on
   2026-08-11 [Bertrand] — the word did not fit the site. Its rules are gone
   with it rather than left dead in the stylesheet.

   The reasoning it carried is NOT gone, because it was right: a render
   presented as a photograph is the worst failure available on this site, and
   alt text is not a disclosure to anyone who can see. That job now belongs to
   the sub-head on each index, in David's own vocabulary — his MIDA bio says
   "realistic 3d visuals", so the pages say the same. See the ledes on
   work-residential.html and work-commercial.html; do not remove them without
   putting the disclosure somewhere else first.                              */

/* ------------------------------------------------------ exhibition head -- */

/* Measures are set in em against the element's own size, never in ch against a
   parent's. A ch on this section resolves at the 14px body size, so a 27ch cap
   here put a 68px heading inside a 190px column and threw it out of the box.  */
.idx-head {
  padding: calc(var(--rhythm-lg) + 60px) var(--gutter) var(--rhythm);
  text-align: center;
}
.idx-head h1 {
  max-width: 11em;
  margin: 1rem auto 0;
  font-size: clamp(32px, 4.6vw, 68px);
  line-height: 1.1;
  text-wrap: balance;
}
.idx-lede {
  max-width: 33em;
  margin: 1.75rem auto 0;
  font-size: clamp(15px, 1.1vw, 16px);
  color: var(--ink-55);
  line-height: 1.8;
}
.idx-pending { margin: 1.75rem 0 0; }

/* ---------------------------------------------------------- index tools -- */
/* The reference carries a category filter and a grid/list toggle. Both are
   real buttons with aria-pressed rather than links, because nothing about the
   page's address changes — and both work with the motion layer absent.      */

.idx-tools {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 1.25rem 2rem;
  margin: 0 var(--gutter);
  padding-bottom: 1.1rem;
  border-bottom: 1px solid var(--ink-14);
}
.idx-filters, .idx-views { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.6rem 1.6rem; }
/* The whole strip used to rest on --ink-30, which measures 2.09:1 on the cream
   at 12px — a control you cannot read is not a control. Rest moves to --ink-55
   (4.61:1) and hover moves up out of its way, so there are still three legible
   steps between untouched, hovered and pressed.                              */
.idx-legend { color: var(--ink-55); }
.idx-btn {
  position: relative;
  margin: 0;
  padding: 0 0 4px;
  border: 0;
  background: none;
  font-family: inherit;
  color: var(--ink-55);
  cursor: pointer;
  transition: color 0.35s var(--ease-out);
}
.idx-btn::after {
  content: "";
  position: absolute;
  inset: auto 0 0 0;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.45s var(--ease);
}
.idx-btn:hover { color: rgba(0, 0, 0, 0.8); }
.idx-btn[aria-pressed="true"] { color: var(--ink); }
.idx-btn[aria-pressed="true"]::after { transform: scaleX(1); }
.idx-count { color: var(--ink-55); }

/* ------------------------------------------------------------ the grid --- */
/* Multi-column rather than a row grid: with portrait and landscape frames
   mixed, a row grid aligns every row to its tallest card and opens a band of
   dead space under the short ones. Columns pack instead, and filtering is a
   native reflow rather than a re-layout pass.                               */

.idx-grid {
  columns: 3;
  column-gap: clamp(20px, 2.4vw, 40px);
  padding: var(--rhythm) var(--gutter) var(--rhythm-lg);
}
.idx-card {
  display: flow-root;
  break-inside: avoid;
  -webkit-column-break-inside: avoid;
  margin: 0 0 clamp(26px, 3.2vw, 52px);
}
.idx-shot {
  position: relative;
  overflow: hidden;
  background: rgba(0, 0, 0, 0.05);
}
.idx-shot img { width: 100%; height: auto; }
.idx-meta { padding-top: 0.95rem; }
.idx-meta h3 {
  margin: 0.45rem 0 0;
  font-size: clamp(16px, 1.25vw, 19px);
  line-height: 1.35;
  letter-spacing: 0;
}
.idx-grid.is-parallax .idx-card { will-change: transform; }

@media (max-width: 1000px) { .idx-grid { columns: 2; } }
@media (max-width: 620px)  { .idx-grid { columns: 1; } }

/* Two up on a wide screen, not three [Bertrand, 2026-08-11]. Three columns put
   every image at 390px on a 1440 laptop — the work was legible but never
   large. Two puts it near 600px at 1440 and 840px at 1920. The count drops as
   the screen grows, which looks backwards written down and is the entire
   point: a gallery should spend extra width on the pictures, not on more of
   them.                                                                     */
@media (min-width: 1400px) {
  .idx-grid { columns: 2; column-gap: clamp(32px, 3vw, 64px); }
}

/* The full-width break. `column-span: all` ends the current column flow, lays
   the card across the whole measure, and starts the columns again underneath —
   which is the rhythm itself, not a decoration on it. It is opt-in per card
   rather than an :nth-child rule because these grids filter, and hidden cards
   still count for :nth-child, so a positional rule would move the breaks
   around whenever a visitor picked a category.                              */
.idx-card.is-wide {
  column-span: all;
  margin-inline: calc(var(--gutter) * -1);
  margin-block: clamp(32px, 4vw, 72px);
}
.idx-card.is-wide .idx-shot img { width: 100%; height: auto; }
.idx-card.is-wide .idx-meta { padding-inline: var(--gutter); }
/* One column is already the full measure; spanning it would only add margins. */
@media (max-width: 620px) {
  .idx-card.is-wide { column-span: none; margin-inline: 0; }
}
.idx[data-view="list"] .idx-card.is-wide { column-span: none; margin-inline: 0; }

/* List view. Same cards, same DOM, one column of rows — the reference's own
   second view, and the one that reads better on a phone.                    */

.idx[data-view="list"] .idx-grid {
  columns: 1;
  max-width: var(--content);
  margin: 0 auto;
  padding-top: 0;
}
.idx[data-view="list"] .idx-card {
  display: grid;
  grid-template-columns: clamp(104px, 16vw, 232px) minmax(0, 1fr);
  align-items: center;
  gap: clamp(16px, 3vw, 44px);
  margin: 0;
  padding: clamp(14px, 1.7vw, 22px) 0;
  border-bottom: 1px solid var(--ink-14);
}
.idx[data-view="list"] .idx-shot { aspect-ratio: 4 / 3; }
.idx[data-view="list"] .idx-shot img { height: 100%; object-fit: cover; }

.idx[data-view="list"] .idx-meta { padding: 0; }
.idx[data-view="list"] .idx-meta h3 { font-size: clamp(17px, 1.9vw, 26px); }

/* The old comment here read "kept last in this block so it outranks the
   list-view display rule", and that was wrong: source order only breaks ties
   at EQUAL specificity. `.idx[data-view="list"] .idx-card` is (0,3,0) and
   `.idx-card[hidden]` is (0,2,0), so the list view won and filtering silently
   did nothing in list mode — the count said "1 piece" while all 37 stayed on
   screen. Found by independent verification 2026-08-11; it had been live since
   the list view shipped.

   `!important` rather than a more specific selector, because `[hidden]` means
   "this element is not rendered" and that should not be losable by any future
   rule. It is what the HTML spec's own UA stylesheet is doing, and it ends the
   specificity race instead of winning one round of it.                       */
.idx-card[hidden] { display: none !important; }

.idx-empty { padding: var(--rhythm) var(--gutter) var(--rhythm-lg); text-align: center; }

/* ------------------------------------------------------------ solo piece -- */
/* One published frame is not a grid. It takes the whole opening viewport
   instead, and this is the caption underneath it: what the piece is, what
   category it answers to, and — said out loud rather than papered over —
   that there is only one of it.                                            */

.solo { padding: var(--rhythm-lg) var(--gutter); }
.solo-body { max-width: 620px; margin: 0 auto; text-align: center; }
.solo-body h2 { margin: 1rem 0 0; font-size: clamp(26px, 3.2vw, 44px); line-height: 1.15; }
.solo-body p { margin: 1.6rem 0 0; line-height: 1.8; color: var(--ink-55); }
.solo-pending { margin-top: 2.25rem; }

/* ------------------------------------------------------------- studio ---- */

.bio { padding: var(--rhythm-lg) 0; }
.bio-grid {
  display: grid;
  grid-template-columns: minmax(0, 260px) minmax(0, 1fr);
  gap: clamp(28px, 5vw, 92px);
  align-items: start;
}
.bio-grid h2 { font-size: clamp(21px, 2.1vw, 28px); line-height: 1.25; max-width: 8em; }
.bio-lede {
  margin: 0;
  font-family: var(--display);
  font-size: clamp(22px, 2.6vw, 36px);
  line-height: 1.35;
  letter-spacing: -0.005em;
}
.bio-body > p { margin: 1.6rem 0 0; max-width: 41em; line-height: 1.8; color: var(--ink-55); }
.bio-body > p:first-child { margin-top: 0; }
.bio-body > .bio-lede { max-width: 22em; color: var(--ink); }
.bio-note {
  margin: 2.75rem 0 0;
  padding-top: 1.4rem;
  border-top: 1px solid var(--ink-14);
  max-width: 41em;
  line-height: 1.7;
}

@media (max-width: 860px) {
  .bio-grid { grid-template-columns: 1fr; }
}

/* .figure / .figure-shot / .svc-quote lived here and are gone with the studio
   page's hand-drawn elevation [Bertrand, 2026-08-10]. .svc-quote in particular
   was load-bearing scaffolding for that one adjacency — the figure's bottom
   padding stood in for the section's own top padding — so keeping it after the
   figure went would have closed the gap it was written to open.               */

.roster { padding: 0 0 var(--rhythm-lg); }
.roster h2 { margin: 1rem 0 0; font-size: clamp(26px, 3.2vw, 46px); line-height: 1.15; max-width: 22ch; }
.roster-list { margin: var(--rhythm) 0 0; padding: 0; list-style: none; }
.roster-row {
  display: grid;
  grid-template-columns: 4.5rem minmax(0, 1fr);
  gap: 0.5rem 1.5rem;
  padding: 1.6rem 0;
  border-top: 1px solid var(--ink-14);
}
.roster-row h3 { font-size: clamp(19px, 1.9vw, 26px); line-height: 1.3; }
.roster-row p { margin: 0.7rem 0 0; max-width: 52ch; line-height: 1.75; color: var(--ink-55); }
.roster-types {
  margin: 2.5rem 0 0;
  max-width: 68ch;
  line-height: 2;
  color: var(--ink-55);
}

@media (max-width: 620px) {
  .roster-row { grid-template-columns: 1fr; gap: 0.35rem; }
}

/* -------------------------------------------------------------- pager --- */

.pager {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  align-items: center;
  gap: 1.5rem;
  margin: 0 var(--gutter);
  padding: 2.4rem 0 var(--rhythm-lg);
  border-top: 1px solid var(--ink-14);
}
.pager a { display: block; }
.pager-word {
  display: block;
  margin-top: 0.45rem;
  font-family: var(--display);
  font-size: clamp(20px, 2.4vw, 33px);
  line-height: 1.15;
}
.pager a:hover .pager-word { opacity: 0.62; }
.pager-mid { text-align: center; }
.pager-next { text-align: right; }

@media (max-width: 620px) {
  .pager { grid-template-columns: 1fr; gap: 1.75rem; text-align: left; }
  .pager-mid, .pager-next { text-align: left; }
}

/* ------------------------------------------------------------ contact --- */
/* The reference floats a cream card over a full-bleed frame. That is exactly
   what .closing already is, so contact wears .closing and widens the card
   instead of inventing a second species for the same idea.

   There is no form. Nothing here posts anywhere yet, and a form that silently
   drops an enquiry is worse than no form at all — the channels below are the
   ones that actually reach him.                                             */

.contact-stage {
  height: auto;
  min-height: 100svh;
  overflow: clip;
  padding-top: calc(var(--rhythm) + 60px);
}
.contact-card {
  width: min(640px, 100%);
  padding: clamp(30px, 4vw, 60px);
  text-align: left;
}
.contact-card h1 {
  margin: 0.9rem 0 0;
  font-size: clamp(30px, 3.4vw, 46px);
  line-height: 1.14;
}
.contact-lede { margin: 1.3rem 0 0; max-width: 44ch; line-height: 1.75; color: var(--ink-55); }
.card-rows { margin: 2.5rem 0 0; }
.card-row {
  display: grid;
  grid-template-columns: 7rem minmax(0, 1fr);
  gap: 0.35rem 1.5rem;
  padding: 1.05rem 0;
  border-top: 1px solid var(--ink-14);
}
.card-row dd { margin: 0; }
.card-val { font-family: var(--display); font-size: clamp(19px, 1.7vw, 24px); line-height: 1.3; }
.card-val a { border-bottom: 1px solid var(--ink-55); padding-bottom: 2px; }
.card-val a:hover { border-bottom-color: var(--ink); }
.card-note { margin: 0.5rem 0 0; line-height: 1.6; color: var(--ink-55); }
.card-social { display: flex; flex-wrap: wrap; gap: 0.4rem 1.5rem; margin: 2.25rem 0 0; }
.card-social a { border-bottom: 1px solid var(--ink-14); padding-bottom: 3px; }
.card-social a:hover { border-bottom-color: var(--ink); }

@media (max-width: 620px) {
  .card-row { grid-template-columns: 1fr; }
}

/* --------------------------------------------------------- entrances ---- */
/* Openers only, and CSS only. A scroll-triggered gsap.from on content further
   down the page is one missed trigger away from leaving that content at zero
   opacity — the failure already found once on the footer wordmark — so the
   pages below carry no reveal that can strand anything. Everything here
   starts visible under reduce, because the rules never apply there.         */

@media (prefers-reduced-motion: no-preference) {
  .chead-inner > * { animation: lift 1.1s var(--arrive) both; }
  .chead-inner > *:nth-child(2) { animation-delay: 0.12s; }
  .chead-inner > *:nth-child(3) { animation-delay: 0.24s; }
  .chead-inner > *:nth-child(4) { animation-delay: 0.36s; }
  .idx-head > * { animation: lift 1.1s var(--arrive) both; }
  .idx-head > *:nth-child(2) { animation-delay: 0.12s; }
  .idx-head > *:nth-child(3) { animation-delay: 0.24s; }
  .idx-head > *:nth-child(4) { animation-delay: 0.36s; }

  @keyframes lift {
    from { opacity: 0; transform: translateY(26px); }
    to   { opacity: 1; transform: none; }
  }
}

/* ------------------------------------------------------------- addenda --- */
/* Four small rules the six pages need and index.html never did.             */

/* aria-current with no visible counterpart is a promise kept only to screen
   readers. The nav already draws a rule on hover; standing on the page draws
   the same rule and leaves it there.                                        */
.hdr-nav a[aria-current="page"]::after { transform: scaleX(1); }

/* .idx-tools is display:flex, which outranks [hidden]'s display:none. Without
   this the filter strip would be visible before pages.js has made it work.  */
.idx-tools[hidden] { display: none; }

/* Studio's title is the only two-word one, and at 0.18em tracking it does not
   fit a single line at the 1200px gutter step. It is set as two lines
   deliberately rather than left to wrap wherever the viewport decides.      */
.chead-stack span { display: block; }

/* A fifth child appears on the opener whenever a Visualisation label does.  */
@media (prefers-reduced-motion: no-preference) {
  .chead-inner > *:nth-child(5) { animation-delay: 0.48s; }
}

.card-val { margin: 0; }
