/* ============================================================
   HERO CAROUSEL — experimental. Loaded only by hero-carousel.html.
   Delete this file, js/hero-carousel.js and hero-carousel.html to remove.

   A faked 3D orbit: items are placed on an ellipse and their depth drives
   scale, vertical offset, opacity, blur, stacking order and shadow weight.
   Everything is sized in cqw so the orbit scales with the artwork, which
   already declares container-type: inline-size.
   ============================================================ */

/* ============================================================
   Strata colour along the torn bottom edge of the artwork.
   The figure already carries a jagged mask, but dark rock tearing into a
   dark band made it almost invisible. This lays banded colour across the
   last stretch of the image; the existing mask then cuts its lower edge into
   the same jagged silhouette, so the tear reads as exposed rock layers.
   Sits above the photograph but below the orbit, which is z-index 3-4.
   ============================================================ */
/* The figure keeps its jagged mask, and the colour bands live INSIDE it so
   the same mask cuts both the photograph and the bands at exactly the same
   zigzag. Below that line there is simply nothing drawn, so the hero's own
   blurred backdrop and vignette carry straight through.

   The earlier version ended the bands in an opaque --slate-deep and relied on
   that matching whatever sat beneath the figure. It didn't: the area below
   was the backdrop plus vignette, mottled and darker, which is what showed as
   a hard line. Matching a colour to a textured background can't work; not
   drawing one is the fix. */
.tearband {
  position: absolute;
  inset: 0;
  z-index: 2;
  line-height: 0;
  pointer-events: none;
}
.tearband svg { display: block; width: 100%; height: 100%; }
.tearband .z1 { fill: var(--sandstone); }
.tearband .z2 { fill: var(--ochre); }
.tearband .z3 { fill: var(--clay); }
.tearband .z4 { fill: var(--clay-deep); }


.orbit {
  position: absolute;
  inset: 0;
  z-index: 4;
  cursor: grab;
  /* Let vertical page scrolling through the hero still work on touch */
  touch-action: pan-y;
  user-select: none;
  -webkit-user-select: none;
}
.orbit.is-dragging { cursor: grabbing; }

/* Framed like a specimen plate on a gallery wall: aged walnut moulding, a
   brass fillet on the inner edge, a cream mat, then the photograph. Three
   materials rather than one flat rule. */
.orbit__item {
  position: absolute;
  left: 0;
  top: 0;
  width: 26cqw;
  aspect-ratio: 4 / 3;
  transform-origin: center;
  will-change: transform, opacity;
  padding: 0.85cqw;                       /* the moulding */
  border-radius: 0.28cqw;
  background:
    linear-gradient(148deg, #574430 0%, #33271B 38%, #4A3924 68%, #2B2117 100%);
  box-shadow:
    /* brass fillet on the inside edge of the moulding */
    inset 0 0 0 0.16cqw rgba(200, 160, 94, 0.9),
    /* light catching the top of the frame */
    inset 0 0.12cqw 0.24cqw rgba(255, 228, 175, 0.22),
    inset 0 -0.12cqw 0.3cqw rgba(0, 0, 0, 0.45),
    /* cast shadow, weight and drop driven by depth from JS */
    0 var(--shadow-y, 1cqw) var(--shadow-blur, 2cqw) rgba(14, 10, 6, var(--shadow-a, 0.35));
  /* JS writes transform, opacity, filter, zIndex, visibility and --shadow */
}

/* Shadow pooling on the ground beneath the frame. Sits behind the moulding
   in the item's own stacking context, so only the part that extends past the
   frame is seen — which is what makes it read as cast onto a surface rather
   than as an outline around the picture. */
.orbit__item::before {
  content: "";
  position: absolute;
  left: 50%;
  /* -7% is half the ellipse's own height, which puts its centre on the frame's
     bottom edge; --ground-drop then pushes it further down as the frame comes
     forward. Without that half-height offset the ellipse hides behind the
     moulding and only a sliver shows. */
  bottom: calc(-7% - var(--ground-drop, 3%));
  width: 92%;
  height: 14%;
  z-index: -1;
  transform: translateX(-50%);
  border-radius: 50%;
  background: radial-gradient(ellipse at center,
    rgba(10, 7, 4, var(--ground-a, 0.3)) 0%,
    rgba(10, 7, 4, calc(var(--ground-a, 0.3) * 0.55)) 42%,
    transparent 72%);
  filter: blur(var(--ground-blur, 0.6cqw));
  pointer-events: none;
}

/* Faint sheen, as though under glass */
.orbit__item::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 0.28cqw;
  background: linear-gradient(118deg,
    rgba(255, 250, 236, 0.16) 0%,
    rgba(255, 250, 236, 0.05) 26%,
    transparent 46%);
  pointer-events: none;
}

.orbit__item img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  /* the mat */
  border: 0.5cqw solid #E9DCC0;
  border-radius: 0.06cqw;
  box-shadow: 0 0 0 0.06cqw rgba(60, 44, 26, 0.55);
}

/* --- Caption, front item only --------------------------------- */
.orbit__caption {
  position: absolute;
  left: 50%;
  /* Clear of the front frame, which reaches ~70% at full scale */
  top: 82%;
  transform: translateX(-50%);
  width: min(46cqw, 90%);
  text-align: center;
  font-family: var(--font-body);
  font-size: max(11px, 1.35cqw);
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: #FFF3DC;
  text-shadow: 0 1px 6px rgba(16, 12, 7, 0.95), 0 0 16px rgba(16, 12, 7, 0.8);
  pointer-events: none;
  transition: opacity 260ms var(--ease);
}
.orbit__caption.is-swapping { opacity: 0; }

/* ============================================================
   Copley marker. Replaces the state motto.
   41.088°N / 81.640°W inside Ohio's bounding box (80.52–84.82°W,
   38.40–41.98°N) lands at 73.96% / 24.92% of the state, which maps to
   57.4% / 32.9% of the artwork given the cutout's measured bounds.
   ============================================================ */
.copley {
  position: absolute;
  left: 57.4%;
  top: 32.9%;
  transform: translate(-50%, -50%);
  z-index: 3;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.4cqw;
  pointer-events: none;
}
.copley__dot {
  position: relative;
  width: 1.5cqw;
  height: 1.5cqw;
  border-radius: 50%;
  background: #FFE9B8;
  box-shadow: 0 0 0.6cqw rgba(255, 224, 160, 0.9), 0 0.1cqw 0.3cqw rgba(20, 14, 8, 0.8);
}
/* The ping: a ring expanding out of the dot, fired by JS each time a new
   photo reaches the front of the orbit, so it beats in time with rotation. */
.copley__dot::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 0.22cqw solid rgba(255, 226, 168, 0.9);
  opacity: 0;
}
.copley.is-pinging .copley__dot::after {
  animation: copley-ping 900ms cubic-bezier(0.16, 0.8, 0.34, 1) forwards;
}
@keyframes copley-ping {
  0%   { transform: scale(1);   opacity: 0.95; }
  100% { transform: scale(4.6); opacity: 0; }
}
.copley__label {
  font-family: var(--font-body);
  font-size: max(8px, 1.02cqw);
  font-weight: 600;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: #FFF3DC;
  text-shadow: 0 1px 5px rgba(16, 12, 7, 0.95);
  white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
  .copley.is-pinging .copley__dot::after { animation: none; }
}

/* ============================================================
   Phones: no carousel. At 375px the front frame renders under 100px wide,
   which communicates nothing and costs a full animation loop. The JS also
   short-circuits below this width so nothing runs off-screen.
   ============================================================ */
/* ============================================================
   Statement shown inside the Ohio outline instead of the carousel.
   Hidden by default; the narrow-viewport rules below switch the two.
   Positioned on the cutout's measured centre, 49.5% / 49.3%.
   ============================================================ */
.hero__statement {
  display: none;
  position: absolute;
  left: 49.5%;
  top: 49.3%;
  width: 33%;
  transform: translate(-50%, -50%);
  z-index: 3;
  text-align: center;
  /* CINZEL, AND IT IS THE SITE'S FOURTH TYPEFACE. Chosen by the user 2026-09-05
     from eight candidates rendered into this exact cutout at their own fitted
     sizes. Declared here as a literal rather than promoted to a token in
     tokens.css ON PURPOSE: the three tokens there are a SYSTEM (display, body,
     scripture) and this is a one-off for a piece of artwork. Giving it a token
     would invite it to be reached for elsewhere, which is the same failure
     --font-scripture's comment warns about. It styles ONE element on ONE page.
     Cinzel is inscriptional Roman capitals — carved-plaque lettering — which is
     why it suits a museum statement set in caps inside a state outline. It has
     no lowercase worth the name, which does not matter here: this element is
     text-transform: uppercase and always has been.
     Fallbacks match --font-display's so a failed webfont degrades to the same
     serif the rest of the page uses. */
  font-family: "Cinzel", "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
  /* Settings chosen by measuring fill, not by eye. Sweeping Fraunces and
     Barlow across weights, sizes, line-heights and top padding, this pair
     filled the most of the outline without overflowing it: 5 lines, 68% of
     the height, 75% mean line width. Caps at 700 beat mixed case because
     more, shorter lines follow a tapering silhouette better.
     RE-MEASURED 2026-09-05 across eight faces, all auto-fitted to the largest
     size whose last line still clears the bottom of the shape. Every one of
     them settled on 5 lines; what separated them was fill. Cinzel 700 reaches
     68% of the box height at 73% mean line width. The two that filled more
     were rejected on other grounds: Abril Fatface (75%) is a didone whose
     hairlines thin out over the rock texture, and Anton reached 88% only by
     going narrow — 59% line width, leaving both shoulders of the state empty,
     which is the same failure that eliminated Barlow the first time round. */
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.005em;
  font-size: max(16px, 6.4cqw);
  line-height: 1;
  /* Warm gold rather than white. Measured against the cutout's average
     background of rgb(93,79,52): cream scores 7.33 contrast, this holds
     6.09, while sandstone and ochre collapse to 3.3 and 2.1 and can't carry
     text here. Sits between --sand and --cream, so it stays on palette. */
  color: #F4D9A0;
  text-shadow:
    0 2px 4px rgba(22, 16, 8, 0.95),
    0 0 10px rgba(22, 16, 8, 0.85),
    0 0 26px rgba(22, 16, 8, 0.55);
  pointer-events: none;
}

/* ============================================================
   Narrow viewports: no carousel, no marker, statement instead.
   Detected by width, not by device — a small desktop window gets the same
   treatment, which is the behaviour you actually want.
   ============================================================ */
@media (max-width: 900px) {
  .orbit,
  .orbit__caption,
  .copley { display: none; }

  .hero__statement { display: block; }
}

/* ============================================================
   Phones: zoom the artwork so the Ohio outline is larger and the statement
   has room. The frame goes from 16:9 to 4:3 and the image switches to
   `cover`, which trims the surrounding rock at the sides.

   The OUTLINE itself is still never cropped: it spans 33–66% of the source
   horizontally, and this crop only removes outside 12.6% and 87.4%. The crop
   is centred, so the cutout's horizontal midpoint stays at ~49.3% and the
   statement's position needs no adjustment.
   ============================================================ */
@media (max-width: 900px) {
  /* Ohio's top edge sits at 16.5% of the frame height, so the frame has to be
     at least headerHeight / 0.165 tall for the outline to clear the bar.
     At ~64px of header that's 388px, hence the floor. Without it a 375px
     phone gives a 281px frame and the bar cuts across the state.
     `container-type: size` unlocks cqh, which is what the statement below
     needs: the cutout's WIDTH is a function of the frame's HEIGHT once the
     image is cropped, so sizing it in cqw would drift as the aspect changes. */
  .hero__figure {
    aspect-ratio: 4 / 3;
    min-height: 400px;
    container-type: size;
  }
  .hero__img { object-fit: cover; object-position: 50% 50%; }

  /* Box laid exactly over the cutout's bounding box, measured: the crop is
     centred so the midpoint stays at 49.3%, and the outline occupies 44.3%
     of the figure's width and 65.6% of its height. Aligning the box to the
     bounds is what lets the shapes below line up with the real border. */
  /* Sized from the frame's HEIGHT, not its width. With the image cropped to
     `cover`, the displayed picture is frameHeight x 1.786 wide, and Ohio
     occupies 33% of that — so the cutout measures 0.589 x frameHeight
     across, regardless of how wide the phone is. */
  .hero__statement {
    left: 49.2%;
    top: 16.5cqh;
    width: 58.9cqh;
    height: 65.6cqh;
    transform: translateX(-50%);
    /* 7.75cqh = 31px at the 400px frame height, the largest Cinzel that still
       clears the bottom of the outline with the SIX-line wording.
       THIS NUMBER IS A FUNCTION OF THE FACE AND THE COPY TOGETHER, and it has
       been re-fitted twice for that reason: 7.4 (Fraunces, 5 lines) -> 8.38
       (Cinzel, 5 lines) -> 7.75 (Cinzel, 6 lines, "Education" added
       2026-09-05). Do not read it as a size preference and do not tune it by
       eye. RE-FIT IT IF THE FACE OR THE COPY EVER CHANGES: sweep the size up
       and keep the largest where the last line's bottom still clears the box.
       Counter-intuitively the extra line FILLS MORE, not less — 75% of the box
       height against the 5-line setting's 68% — because six shorter lines
       follow the state's taper more closely than five longer ones. That is the
       same principle that made caps beat mixed case in the first place. */
    font-size: max(15px, 7.75cqh);
    /* No padding here. The floats sit inside it, so any padding slides the
       Ohio silhouette down with them and the taper stops matching the real
       outline — 15% was enough to scatter the lines completely. The
       .ohio-shape--top spacer holds the text down instead, which leaves the
       shapes anchored to the box. */
    padding-top: 0;
  }
}

/* ============================================================
   Text shaped to the Ohio border.
   Two floats carry shape-outside polygons traced from the artwork itself:
   the cutout was flood-filled and its outermost left and right edges read
   off row by row. Ohio runs full width across the north and narrows to a
   point in the south, so the lower lines pull in from both sides.
   Each float is half the box wide, so its own 0-100% maps to one half of
   the container.
   ============================================================ */
.ohio-shape { display: none; }

@media (max-width: 900px) {
  .ohio-shape {
    display: block;
    width: 50%;
    height: 100%;
    /* no visual presence; these exist purely to displace text */
    pointer-events: none;
  }
  /* Ohio's northern border is the angled Lake Erie shore, so the top of the
     bounding box is almost entirely OUTSIDE the state: at 5% down the outline
     exists only between 85% and 99% across. The side floats can't express
     that, since a half-width float can't reach past 50%. This full-width
     spacer simply holds the text off until the state runs edge to edge. */
  .ohio-shape--top {
    float: left;
    width: 100%;
    height: 16%;
  }

  /* Each edge is pulled 6 float-units (≈3% of the box) inside the traced
     border, so lines keep a margin instead of touching it. Without the inset
     the longest word cleared the outline by 1-2px, which reads as spilling
     even though it technically fits. */
  .ohio-shape--l {
    float: left;
    shape-outside: polygon(
      0% 0%, 10.6% 0%, 10.6% 30%, 9% 45%, 9% 55%,
      7.6% 62%, 6% 72%, 16.6% 80%, 43.8% 90%,
      100% 96%, 100% 100%, 0% 100%
    );
  }
  .ohio-shape--r {
    float: right;
    shape-outside: polygon(
      100% 0%, 100% 32%, 86.4% 50%, 81.8% 60%,
      69.8% 70%, 41% 80%, 19.8% 90%, 0% 96%,
      0% 100%, 100% 100%
    );
  }
}

/* ============================================================
   PHOTO STACK — the mobile counterpart of the orbit above.

   IT LIVES IN THIS FILE, NOT main.css, ON PURPOSE. This stylesheet is linked
   from index.html and nowhere else, and so is the stack; putting it in
   main.css would ship ~180 lines of dead CSS to the other twenty pages.

   The shape of the thing: nine cards in one grid cell, all stacked on the same
   square, each rotated a little so the ones underneath show at the corners.
   `data-depth` is the only state — 0 is the card on top, 1 and 2 are the two
   most recently seen showing behind it, and everything from 3 back is hidden.
   THE PILE UNDERNEATH IS HISTORY, NOT THE QUEUE: a new photo is dealt ONTO
   the stack and the one it covers slides under, which is what makes the throw
   animation read as throwing rather than as a card being peeled off.
   js/photo-stack.js only ever rewrites data-depth; every visual decision is
   here.
   ============================================================ */
.photostack { display: none; }

@media (max-width: 900px) {
  .photostack {
    display: block;
    margin-top: var(--space-lg);
    /* THE INCOMING CARD STARTS OFF-SCREEN, and without this the document grows
       sideways while it flies: measured at 156px of horizontal overflow one
       frame into the throw, which on a phone is a scrollbar flashing and a
       page that can be dragged sideways mid-animation.
       `clip`, not `hidden`: hidden would make this a scroll container, and it
       also forces the other axis to `auto`, which would cut the fanned cards'
       shadows off at the top and bottom. `clip` does neither.
       Clipping HERE rather than on the deck is deliberate — the card is then
       hidden only once it passes the edge of the page, which is exactly the
       "thrown in from off-screen" read. Clipping at the deck would make it
       pop into existence at the pile's own edge instead. */
    overflow-x: clip;
  }
}

.photostack__eyebrow { text-align: center; margin-bottom: var(--space-md); }

/* One grid cell, every card in it. Grid rather than absolute positioning so
   the deck still takes up its own height in the flow — with `position:
   absolute` cards the section would collapse to nothing and the seam below
   would ride up into the photographs. */
.photostack__deck {
  display: grid;
  place-items: center;
  /* --card-w is the frame's own width and every frame measurement below is a
     fraction of it, so this one number sets the whole component's scale.
     The deck is that plus the fan padding on each side. */
  --card-w: min(78vw, 320px);
  --fan-pad: 18px;
  width: calc(var(--card-w) + var(--fan-pad) * 2);
  margin-inline: auto;
  /* No aspect-ratio here: the cards define the height and the deck takes it
     from them. Setting one on the deck as well only fights the card. */
  /* Room for the fanned corners and the shadows to fall outside the cards
     without being clipped by the section's own bounds. */
  padding: var(--fan-pad);
  /* pan-y, NOT none: vertical page scrolling must still belong to the browser.
     This hands horizontal gestures to the script and keeps the rest. */
  touch-action: pan-y;
  cursor: grab;
  -webkit-tap-highlight-color: transparent;
}
.photostack__deck.is-dragging { cursor: grabbing; }
.photostack__deck:focus-visible {
  outline: 3px solid var(--clay);
  outline-offset: 6px;
  border-radius: var(--radius);
}

/* THE FRAME IS `.orbit__item`'s, REBUILT AGAINST --card-w. It has to be
   rebuilt rather than shared: every measurement up there is in `cqw` against
   `.hero__figure`, which is ~1449px on a desktop and does not exist as a
   container down here. So each value is carried over as ITS OWN FRACTION OF
   THE FRAME'S WIDTH, which is what actually makes the two read as the same
   object — the orbit frame is 26cqw wide with 0.85cqw of moulding, i.e. 3.27%
   of itself, and 3.27% is what is written below. If the orbit's frame is ever
   re-tuned, re-derive these the same way; a raw px value copied across would
   be right at one screen width and wrong everywhere else.
   Kept: the walnut moulding gradient, the brass fillet, the lit top and shaded
   bottom edges, the cream mat and its hairline, the glass sheen. Dropped: the
   depth-driven ground-shadow ellipse, which is driven by the orbit's own
   rAF loop and has no depth to read from here. */
.photocard {
  grid-area: 1 / 1;
  position: relative;          /* containing block for the ::after sheen */
  margin: 0;
  width: var(--card-w);
  /* ON THE FRAME, exactly where `.orbit__item` puts it, not on the photo.
     Both boxes are border-box, so the moulding and the mat eat into the 4:3
     and the picture itself ends up slightly wider than 4:3 — which is the
     geometry the orbit already has. Putting the ratio on the img instead
     makes the PHOTO 4:3 and the frame something else, and the two galleries
     stop matching. */
  aspect-ratio: 4 / 3;
  padding: calc(var(--card-w) * 0.0327);
  border-radius: calc(var(--card-w) * 0.0108);
  background:
    linear-gradient(148deg, #574430 0%, #33271B 38%, #4A3924 68%, #2B2117 100%);
  box-shadow:
    inset 0 0 0 calc(var(--card-w) * 0.00615) rgba(200, 160, 94, 0.9),
    inset 0 calc(var(--card-w) * 0.0046) calc(var(--card-w) * 0.0092) rgba(255, 228, 175, 0.22),
    inset 0 calc(var(--card-w) * -0.0046) calc(var(--card-w) * 0.0115) rgba(0, 0, 0, 0.45),
    0 10px 24px rgba(14, 10, 6, 0.3),
    0 2px 5px rgba(14, 10, 6, 0.2);
  /* --jit is a per-card jitter set inline in the markup, ±1.5deg. Without it
     the pile is three cards at three fixed angles and reads as a machined
     fan; with it no two states of the stack look quite alike. --rot, --tx,
     --ty and --sc all come from the data-depth rules below. */
  transform:
    translate(var(--tx, 0px), var(--ty, 0px))
    rotate(calc(var(--rot, 0deg) + var(--jit, 0deg)))
    scale(var(--sc, 1));
  transition: transform 320ms var(--ease), opacity 320ms var(--ease);
}

/* The sheen, as though under glass. `.orbit__item::after` verbatim apart from
   the radius, which follows --card-w like everything else. */
.photocard::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: calc(var(--card-w) * 0.0108);
  background: linear-gradient(118deg,
    rgba(255, 250, 236, 0.16) 0%,
    rgba(255, 250, 236, 0.05) 26%,
    transparent 46%);
  pointer-events: none;
}

/* `width: 100%; height: 100%` is `.orbit__item img` verbatim, and filling a
   framed box that way sidesteps a trap the earlier square card walked into:
   the markup's width/height attributes are a UA presentational hint for the
   `height` PROPERTY, a used height beats `aspect-ratio`, and a 600x800 source
   under `height: auto` rendered 807px tall inside a 270px card. With an
   explicit height there is no `auto` left to defend.

   THE CROP IS CENTRED AND UNMODIFIED, BECAUSE THAT IS WHAT THE ORBIT DOES.
   Four of the nine sources are 3:4 portrait and one is 16:9, so several lose
   a lot — but they lose exactly the same on the desktop carousel, and
   matching it was the instruction. The square card this replaced needed two
   per-image `--pos` nudges to stop it cutting exhibit titles in half; at 4:3
   they are gone, because this is the framing the nine were chosen against. */
.photocard img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* The cream mat and the hairline that separates it from the moulding. */
  border: calc(var(--card-w) * 0.0192) solid #E9DCC0;
  border-radius: calc(var(--card-w) * 0.0023);
  box-shadow: 0 0 0 calc(var(--card-w) * 0.0023) rgba(60, 44, 26, 0.55);
  background: var(--slate-deep);
}

/* One caption below the deck, not one printed on each card. It started inside
   the card, polaroid-style; adopting the orbit's walnut frame killed that,
   because the frame is a moulding around a photograph with no plate to print
   on and a cream strip hanging off its bottom edge looked like a mistake.
   So it moved out and became `.orbit__caption`'s twin instead: same uppercase
   Barlow 600 at the same 0.16em tracking, same cross-fade on `.is-swapping`,
   fed from the same `data-caption` attribute.
   THE COLOUR IS THE ONE THING THAT COULD NOT CARRY OVER. The orbit's caption
   is #FFF3DC over dark rock with a heavy text-shadow behind it; this sits on
   sand, so it takes --text and drops the shadow. Anything else would be
   matching a value instead of matching the intent.
   The height is FIXED at two lines: the nine captions run 17 to 30 characters
   and wrap to one line or two, and letting it size to its text would jog the
   buttons below it up and down on every swipe. */
.photostack__caption {
  margin: var(--space-sm) auto 0;
  max-width: 30ch;
  text-align: center;
  font-family: var(--font-body);
  font-size: var(--step--1);
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  line-height: 1.3;
  color: var(--text);
  height: calc(1.3em * 2);
  display: flex; align-items: center; justify-content: center;
  transition: opacity 260ms var(--ease);
}
.photostack__caption.is-swapping { opacity: 0; }

/* --- The three visible layers ------------------------------------------
   Offsets are deliberately asymmetric. A symmetric fan (equal and opposite
   angles) reads as a design; an uneven one reads as a pile someone put down.

   THE FAN IS BOUNDED BY THE FRAME'S OWN WIDTH, AND THAT IS THE RULE HERE.
   From a card's edge there is 9.56px of walnut plus 5.6px of mat — 14.6px —
   before the photograph starts. So a card behind may overhang the top one by
   up to 14.6px and show nothing but frame; past that its PICTURE starts
   leaking out, which reads as a printing error rather than a stack.

   Earlier values (13px/5.4deg and -14px/-6.6deg) overhung by 13px left and
   15px right and crossed that line — the user spotted the sliver of the next
   photograph on the left. These sit at ~10-12px, verified across all nine
   positions of the pile.

   ROTATION COSTS MORE THAN TRANSLATION HERE, which is the non-obvious part.
   The overhang is not `--tx`: it is `halfExtent(rotation) + tx`, where a 293
   x 219 card's half-extent is `146.5*cos(r) + 109.5*sin(r)`. At 6deg the
   rotation alone adds 11px before any translation. That is why the angles came
   down further than the offsets did, and why `--jit` in the markup was reduced
   to +/-0.6deg — at +/-1.5deg the jitter alone could spend the whole budget.

   --sc IS PART OF THE SUM AND IT IS EASY TO FORGET. Shrinking a card by 5%
   pulls its edge in by ~7.6px, which cancels most of a --tx of the same size:
   a first pass at 6px/-7px measured only 2-5px of overhang and the pile
   stopped reading as a pile at all. These offsets are larger than they look
   for that reason. Measured overhang, all nine positions: 10.5-14.4px. */
.photocard[data-depth="0"] { --rot: -1.2deg; --tx:   0px; --ty:  0px; --sc: 1;     z-index: 3; }
.photocard[data-depth="1"] { --rot:  3.0deg; --tx:  10px; --ty:  7px; --sc: 0.975; z-index: 2; }
.photocard[data-depth="2"] { --rot: -3.8deg; --tx: -13px; --ty: 12px; --sc: 0.95;  z-index: 1; }

/* Everything from the third card back. `visibility: hidden` rather than
   `display: none` — a display change would drop the element out of the grid
   cell and the deck's height would flicker as cards cycle through. */
.photocard[data-depth="3"] {
  --rot: 0deg; --tx: 0px; --ty: 12px; --sc: 0.93;
  z-index: 0; opacity: 0; visibility: hidden;
}

/* --- The throw ----------------------------------------------------------
   One keyframe, `from` only: with no `to` block the browser animates from
   these values to whatever the element's own computed transform is, which is
   its resting data-depth="0" position — including its own --jit. That is why
   this works for nine cards with nine different resting angles and one rule.

   --throw-x and --throw-rot are written by the script, signed by the swipe
   direction, so the photo always arrives from the side your thumb came from. */
@keyframes photocard-throw {
  from {
    transform: translate(var(--throw-x, 120%), -14%) rotate(var(--throw-rot, 16deg)) scale(1.06);
    opacity: 0;
  }
}
.photocard.is-throwing {
  animation: photocard-throw 420ms var(--ease);
  /* The card is mid-flight and must clear the two beneath it. */
  z-index: 4;
}

/* While a thumb is down the top card follows it, damped, via --drag set by
   the script. The transition is off so it tracks the finger instead of
   easing after it. */
.photostack__deck.is-dragging .photocard[data-depth="0"] {
  transition: none;
  --tx: var(--drag, 0px);
}

/* Same trap as `.lightbox[hidden]` below, and the same fix. These controls are
   `hidden` in the markup until the script unhides them, but `display: flex`
   beat the UA's `[hidden] { display: none }`, so with JS off they showed and
   did nothing — which is the exact opposite of the reason they are marked
   hidden. `.subfield[hidden]` in main.css is the site's earlier encounter with
   this; check for a `display` declaration whenever markup relies on `hidden`. */
.photostack__controls[hidden] { display: none; }

.photostack__controls {
  display: flex; align-items: center; justify-content: center;
  gap: var(--space-md);
  margin-top: var(--space-md);
}
.photostack__nav {
  display: grid; place-items: center;
  width: 44px; height: 44px;
  background: none;
  border: 1px solid var(--rule);
  border-radius: 50%;
  color: var(--ink);
  cursor: pointer;
  transition: border-color 160ms var(--ease), color 160ms var(--ease);
}
.photostack__nav svg { width: 13px; height: 13px; }
.photostack__nav:hover { border-color: var(--clay); color: var(--clay); }
.photostack__count {
  font-family: var(--font-body);
  font-size: var(--step--1);
  text-transform: uppercase;
  letter-spacing: var(--tracking-eyebrow);
  color: var(--text-muted);
  min-width: 6ch; text-align: center;
}

@media (prefers-reduced-motion: reduce) {
  /* No flight and no easing: the new card is simply there. The script checks
     the same query before it sets --throw-x, so nothing here has to undo a
     transform that was never applied. */
  .photocard { transition: none; }
  .photocard.is-throwing { animation: none; }
}

/* ============================================================
   ENLARGED VIEW — tap a card, get the whole photograph.

   Shown at 1600px from assets/img/carousel-full/, which is the FULL frame:
   the cards crop to 4:3 and three of the nine are portrait crops of landscape
   originals, so this is a wider picture than the one that was tapped, not
   just a bigger one.

   The overlay is fixed and covers the page, so the homepage underneath keeps
   its scroll position and is exactly where it was on close — the script also
   records and restores scrollY rather than trusting `overflow: hidden` to
   hold it, because iOS is not reliable about that.
   ============================================================ */
/* THIS RULE IS LOAD-BEARING AND MUST SIT ABOVE `.lightbox`'S OWN `display`.
   The `hidden` attribute is not magic: it works only through the UA stylesheet's
   `[hidden] { display: none }`, which is (0,1,0) and loses to ANY author
   `display` declaration on the element. `.lightbox { display: flex }` therefore
   overrode it and the empty overlay painted over the whole page from the moment
   it loaded — black screen, nothing to close it, because the script's own
   `boxOpen` flag was still false so the X and the arrows correctly did nothing.
   `.lightbox[hidden]` is (0,2,0) and wins.
   The trap it hides behind: `el.hidden` and `el.hasAttribute('hidden')` both
   read TRUE the whole time. Only the computed style tells the truth, so a test
   that asserts on the property passes while the page is unusable. */
.lightbox[hidden] { display: none; }

.lightbox {
  position: fixed;
  inset: 0;
  z-index: 200;                 /* over the fixed header, which is 100 */
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--space-sm);
  padding: var(--space-md) var(--space-sm);
  /* Near-black rather than --ink at full strength: a photograph judges its own
     exposure against its surround, and the ink ramp is blue enough to tint
     what is next to it. */
  background: rgba(8, 11, 14, 0.94);
  /* dvh tracks the phone's collapsing address bar; vh does not, and a photo
     sized against vh is cut off behind the browser chrome on first paint. */
  --lb-vh: 100vh;
  /* EVERYTHING IN THE COLUMN THAT IS NOT THE PHOTOGRAPH, COUNTED RATHER THAN
     ESTIMATED. This was `118px` and the true figure is 124 — padding top and
     bottom, the gap, and the bar — and those six pixels were the whole of the
     sliver bug. `--lb-chrome` sets the stage's WIDTH (below); the flex column
     sets its HEIGHT. Guess the chrome low and the two disagree: the stage asks
     for a height the column has not got, `flex-shrink` quietly grants the width
     and takes the height, and a stage that is no longer the photograph's shape
     letterboxes the photograph inside it — 2.25px of the blurred stand-in
     showing down each side on every portrait photo, which reads as a sliver of
     the same picture beside itself. Deriving it from the tokens keeps the two
     in step if the spacing scale ever moves; `--lb-bar` is enforced as a real
     height on `.lightbox__bar` rather than assumed. */
  --lb-bar: 44px;
  --lb-chrome: calc(var(--space-md) * 2 + var(--space-sm) + var(--lb-bar));
  animation: lightbox-in 200ms var(--ease);
}
@supports (height: 100dvh) { .lightbox { --lb-vh: 100dvh; } }
@keyframes lightbox-in { from { opacity: 0; } }

/* THE STAGE IS EXACTLY THE PHOTOGRAPH, AND THAT IS THE WHOLE POINT.
   The instruction was that tapping outside the boundary of the photo closes
   it, so there must be no box around the picture that still counts as "on"
   it. A fixed panel with `object-fit: contain` inside would letterbox, and
   the dead bars beside the image would swallow taps meant to dismiss.
   Instead --ar (the full file's own width/height, written by the script from
   data-full-w/h) sizes the stage to the picture: width is whichever of the
   viewport's width or the available height allows, and aspect-ratio supplies
   the other axis. The element's edge and the photograph's edge are the same
   line. */
.lightbox__stage {
  position: relative;
  aspect-ratio: var(--ar, 1.3333);
  width: min(100%, calc((var(--lb-vh) - var(--lb-chrome)) * var(--ar, 1.3333)));
  max-height: calc(var(--lb-vh) - var(--lb-chrome));
  border-radius: 2px;
  overflow: hidden;
  background: #0C1014;
  box-shadow: 0 18px 50px rgba(0, 0, 0, 0.55);
}

/* The card's own thumbnail, already in cache, blown up and blurred to stand in
   while the 1600px file arrives — 136-344KB is quick but not instant, and a
   black rectangle for a second reads as a broken tap. It is `cover` because
   it is a 4:3 crop being shown in the FULL photo's box, and aria-hidden
   because it is the same picture announced twice. */
.lightbox__thumb {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  filter: blur(14px) saturate(0.9);
  transform: scale(1.08);       /* hides the blur's soft edge */
  transition: opacity 260ms var(--ease);
}
/* THE STAND-IN LEAVES WHEN THE PHOTOGRAPH ARRIVES. It has no job once the
   1600px file is on screen, and while it is still painted underneath, any
   disagreement between the stage's shape and the file's shape shows up as a
   strip of the same picture, blurred and magnified, beside itself. The chrome
   figure above is what made them disagree; this makes the disagreement
   invisible if anything ever makes them disagree again — the stage's own
   near-black ground is what a seam would show instead. It cross-fades against
   the photograph's own fade-in over the same 260ms, and the two are the same
   picture, so there is nothing to see in the swap. */
.lightbox__stage.is-full .lightbox__thumb { opacity: 0; }
.lightbox__img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  /* `contain`, though the stage already IS this file's aspect ratio — the two
     agree, and contain is what keeps it honest if a data-full-w/h attribute is
     ever wrong. */
  object-fit: contain;
  opacity: 0;
  transition: opacity 260ms var(--ease);
}
.lightbox__img.is-loaded { opacity: 1; }

/* Declared, not inferred: --lb-chrome adds this figure up to decide how much
   height the photograph may have, so the bar has to actually be it. The
   buttons are 44px and the caption is two lines of --step--1, which is
   shorter, so 44 was already the height — this only stops it drifting. */
.lightbox__bar {
  display: flex; align-items: center; justify-content: center;
  gap: var(--space-md);
  width: min(100%, 420px);
  height: var(--lb-bar);
}
.lightbox__caption {
  flex: 1;
  margin: 0;
  text-align: center;
  font-family: var(--font-body);
  font-size: var(--step--1);
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  line-height: 1.3;
  /* Cream on near-black, which is `.orbit__caption`'s colour — this overlay is
     the one place on mobile where the caption sits on a dark ground again, so
     it can go back to the carousel's own value. */
  color: #FFF3DC;
  height: calc(1.3em * 2);
  display: flex; align-items: center; justify-content: center;
  transition: opacity 200ms var(--ease);
}
.lightbox__caption.is-swapping { opacity: 0; }

.lightbox__nav,
.lightbox__close {
  flex: none;
  display: grid; place-items: center;
  width: 44px; height: 44px;
  background: none;
  border: 1px solid rgba(250, 245, 234, 0.28);
  border-radius: 50%;
  color: var(--cream);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: border-color 160ms var(--ease), background 160ms var(--ease);
}
.lightbox__nav:hover,
.lightbox__close:hover { border-color: var(--cream); background: rgba(250, 245, 234, 0.1); }
.lightbox__nav svg { width: 13px; height: 13px; }
.lightbox__close {
  position: absolute;
  top: max(10px, env(safe-area-inset-top));
  right: 10px;
}
.lightbox__close svg { width: 15px; height: 15px; }

/* The page behind must not scroll while this is open, and must not lose where
   it was. `overflow: hidden` on the root does the first; the script does the
   second. */
.u-lightbox-open { overflow: hidden; }

@media (prefers-reduced-motion: reduce) {
  .lightbox { animation: none; }
  .lightbox__img { transition: none; }
  .lightbox__thumb { transition: none; }
  .lightbox__caption { transition: none; }
}
