/* ==========================================================================
   Digital Fusion Photography — components.css
   Buttons, package cards, reviews, testimonial stack, galleries, carousel,
   lightbox, contact.
   ========================================================================== */

/* --------------------------------------------------------------------------
   Buttons
   -------------------------------------------------------------------------- */

/* Smaller, wider-tracked label on a taller block: the button reads as a
   typographic object, and the invert on hover IS the site's colour system.
   No lift on hover — the chrome moves for entrances, not for affordance. */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2xs);
  min-height: 52px;
  padding: var(--space-2xs) var(--space-xl);
  border: 1px solid var(--color-ink);
  background: var(--color-ink);
  color: var(--color-paper);
  font-family: var(--font-secondary);
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-widest);
  line-height: 1.2;
  text-align: center;
  text-transform: uppercase;
  text-decoration: none;
  cursor: pointer;
  transition: background-color var(--duration-base) var(--ease-out),
    color var(--duration-base) var(--ease-out),
    border-color var(--duration-base) var(--ease-out),
    transform var(--duration-fast) var(--ease-out);
}

.btn:hover,
.btn:focus-visible {
  background: transparent;
  color: var(--color-ink);
}

.btn:active {
  transform: translateY(1px);
}

.btn--outline {
  background: transparent;
  color: var(--color-ink);
}

.btn--outline:hover,
.btn--outline:focus-visible {
  background: var(--color-ink);
  color: var(--color-paper);
}

/* On dark photography backgrounds */
.btn--light {
  border-color: var(--color-paper);
  background: var(--color-paper);
  color: var(--color-ink);
}

.btn--light:hover,
.btn--light:focus-visible {
  background: transparent;
  color: var(--color-paper);
}

/* Secondary action on dark photography: an outline that fills on hover.
   Pairs with .btn--light wherever two calls to action share a hero or band. */
.btn--ghost {
  border-color: var(--color-paper);
  background: transparent;
  color: var(--color-paper);
}

.btn--ghost:hover,
.btn--ghost:focus-visible {
  background: var(--color-paper);
  color: var(--color-ink);
}

.btn--sm {
  min-height: 42px;
  padding-inline: var(--space-md);
  font-size: var(--text-xs);
}

/* --------------------------------------------------------------------------
   Bulleted lists
   .tick-list is the component; .package__includes is the name it goes by
   inside a package card. Same rules, so the two always match.
   -------------------------------------------------------------------------- */

.tick-list,
.package__includes {
  display: grid;
  gap: var(--space-3xs);
  margin: 0;
  padding: 0;
  list-style: none;
  color: var(--color-ink-soft);
}

.tick-list li,
.package__includes li {
  display: flex;
  gap: var(--space-2xs);
  align-items: baseline;
  line-height: var(--leading-snug);
}

.tick-list li::before,
.package__includes li::before {
  content: "";
  flex: none;
  width: 5px;
  height: 5px;
  margin-top: 0.5em;
  border-radius: 50%;
  background: var(--color-ink);
}

/* Standalone lists sit in body copy, so they get body leading and more air */
.tick-list {
  gap: var(--space-2xs);
}

.tick-list li {
  line-height: var(--leading-body);
}

/* --------------------------------------------------------------------------
   Package cards — weddings, lifestyle, airbnb, brands
   Photograph on one side, the offer on the other, alternating down the page.
   -------------------------------------------------------------------------- */

main {
  counter-reset: package;
}

.package {
  display: grid;
  gap: 0;
  border-top: 1px solid var(--color-line);
  counter-increment: package;
  transition: background-color var(--duration-base) var(--ease-out);
}

.package:last-of-type {
  border-bottom: 1px solid var(--color-line);
}

/* The whole row responds: the surface tints and the photograph zooms with the
   same slow treatment the galleries use. focus-within keeps the effect for
   keyboard visitors once a row carries a link. */
.package:hover,
.package:focus-within {
  background: var(--color-paper-warm);
}

.package__media {
  position: relative;
  aspect-ratio: 4 / 5;
  background: var(--color-paper-warm);
  overflow: hidden;
}

/* --focus is the component-level twin of --hero-focus (layout.css): every
   photograph on the site sits in a box with a fixed aspect ratio and is cropped
   to fill it, so the frame the crop keeps has to be chosen per photograph or it
   defaults to the middle and cuts heads off. Set it inline on the <img> —
   style="--focus: 50% 18%" — reading the pair as the point of the photograph
   that is pinned to the same point of the box: a smaller second number holds on
   to more of the top of the frame, which is where faces are. Left at 50% 50%
   the behaviour is unchanged, so only the photographs that need it carry it. */
.package__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: var(--focus, 50% 50%);
  transition: transform var(--duration-slow) var(--ease-out),
    filter var(--duration-slow) var(--ease-out);
}

.package:hover .package__media img,
.package:focus-within .package__media img {
  transform: scale(1.04);
  filter: brightness(0.94);
}

.package__body {
  display: grid;
  align-content: center;
  gap: var(--space-md);
  padding: clamp(2rem, 1.4rem + 2.6vw, 3.5rem) clamp(1.25rem, 0.75rem + 2.5vw, 3rem);
}

/* Numbered kicker above the name — "01", "02" … counted per page. */
.package__name::before {
  content: "0" counter(package);
  display: block;
  margin-bottom: var(--space-xs);
  font-family: var(--font-secondary);
  font-size: 0.6875rem;
  font-weight: 700;
  letter-spacing: var(--tracking-widest);
  color: var(--color-ink-muted);
}

.package__name {
  font-size: clamp(1.75rem, 1.4rem + 1.6vw, 2.5rem);
  font-weight: var(--weight-heading);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.package__desc {
  color: var(--color-ink-muted);
}

.package__desc p + p {
  margin-top: var(--space-2xs);
}

/* Hairline above the specification, so the descriptive copy and the hard
   numbers read as two distinct parts of the card. */
.package__cols {
  display: grid;
  gap: var(--space-md);
  padding-top: var(--space-md);
  border-top: 1px solid var(--color-line);
}

.package__includes-label,
.package__price-label {
  display: block;
  margin-bottom: var(--space-2xs);
  font-family: var(--font-secondary);
  font-size: 0.6875rem;
  font-weight: 700;
  letter-spacing: var(--tracking-widest);
  text-transform: uppercase;
  color: var(--color-ink-muted);
}

/* The figure is the second-largest type on the page — light, tight and
   unmissable, which is exactly what a rate card should be. */
.package__price {
  font-family: var(--font-primary);
  font-size: clamp(2.25rem, 1.8rem + 1.9vw, 3.25rem);
  font-weight: var(--weight-heading);
  letter-spacing: -0.01em;
  line-height: 1.05;
  white-space: nowrap;
  color: var(--color-ink);
}

@media (min-width: 860px) {
  .package {
    grid-template-columns: 1fr 1fr;
    align-items: stretch;
  }

  /* The photograph fills its half of the row rather than setting the row's
     height. Left in flow, a 2:3 portrait at half the container width stands
     over 900px tall next to about 380px of copy; taking it out of flow lets
     the offer decide the height and the photograph crop to match. */
  .package__media {
    aspect-ratio: auto;
    min-height: clamp(26rem, 38vw, 34rem);
  }

  .package__media img {
    position: absolute;
    inset: 0;
  }

  .package--reverse .package__media {
    order: 2;
  }

  .package__cols {
    grid-template-columns: 1fr auto;
    gap: var(--space-xl);
    align-items: start;
  }

  /* The price stands in its own hairline-ruled column, spec-sheet style. */
  .package__cols > :last-child {
    border-left: 1px solid var(--color-line);
    padding-left: var(--space-xl);
    text-align: left;
  }
}

/* --------------------------------------------------------------------------
   Index rows — the services table of contents
   "01 / WEDDINGS": ghost number, oversized linked title, one-liner, photo.
   Hairline-separated full-width rows; deliberately uniform, like the index
   pages of a printed portfolio. The title's pseudo-element stretches the link
   across the whole row.
   -------------------------------------------------------------------------- */

.index-list {
  display: grid;
}

.index-row {
  position: relative;
  display: grid;
  gap: var(--space-md) var(--gap-split);
  align-items: center;
  padding-block: clamp(2rem, 1.4rem + 2.4vw, 3.5rem);
  border-top: 1px solid var(--color-line);
}

.index-row:last-child {
  border-bottom: 1px solid var(--color-line);
}

/* The number is set in markup with aria-hidden="true" — it is decoration, and
   a counter would read aloud as content in some screen readers. */
.index-row__num {
  font-family: var(--font-primary);
  font-size: clamp(3rem, 2rem + 4vw, 6rem);
  font-weight: 400; /* Times New Roman has no light cut to thin this down to */
  line-height: 1;
  color: var(--color-line);
  transition: color var(--duration-base) var(--ease-out);
}

.index-row:hover .index-row__num,
.index-row:focus-within .index-row__num {
  color: var(--color-ink);
}

/* Title and one-liner share the middle column. */
.index-row__body {
  display: grid;
  gap: var(--space-xs);
}

.index-row__title {
  font-size: clamp(2rem, 1.4rem + 2.6vw, 3.5rem);
  font-weight: var(--weight-heading);
  line-height: 1.1;
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
}

.index-row__title a {
  text-decoration: none;
}

.index-row__title a::after {
  content: "";
  position: absolute;
  inset: 0;
}

.index-row__desc {
  max-width: 52ch;
  color: var(--color-ink-muted);
}

.index-row__media {
  overflow: hidden;
}

/* A 3:2 landscape box fed mostly portrait photographs — the hardest crop on the
   site, keeping only 44% of a 2:3 frame. --focus is doing real work here; see
   the note on .package__media img. */
.index-row__media img {
  width: 100%;
  height: auto;
  aspect-ratio: 3 / 2;
  object-fit: cover;
  object-position: var(--focus, 50% 50%);
  transition: transform var(--duration-slow) var(--ease-out),
    filter var(--duration-slow) var(--ease-out);
}

.index-row:hover .index-row__media img,
.index-row:focus-within .index-row__media img {
  transform: scale(1.03);
  filter: brightness(0.95);
}

@media (min-width: 860px) {
  .index-row {
    grid-template-columns: auto minmax(0, 1fr) minmax(0, 38%);
  }
}

/* --------------------------------------------------------------------------
   Review cards
   -------------------------------------------------------------------------- */

.reviews-grid {
  display: grid;
  gap: var(--gap-card);
}

@media (min-width: 860px) {
  .reviews-grid {
    grid-template-columns: repeat(2, 1fr);
  }
}

/* No card box: each review hangs from a single heavy rule, editorial-style.
   Because the rule and the quote glyph read from the colour tokens, the same
   component works untouched inside a dark contrast block — white rules on
   near-black instead of black rules on white. */
.review {
  position: relative;
  display: grid;
  gap: var(--space-sm);
  align-content: start;
  padding: clamp(1.5rem, 1.1rem + 1.8vw, 2.25rem) 0 0;
  overflow: hidden;
  border-top: 1px solid var(--color-line-strong);
}

/* Oversized opening quote, sitting behind the copy in the empty top corner */
.review::before {
  content: "\201C";
  position: absolute;
  top: -0.1em;
  right: 0.12em;
  font-family: var(--font-display);
  font-size: 8rem;
  line-height: 1;
  color: var(--color-line);
  pointer-events: none;
  transition: color var(--duration-base) var(--ease-out);
}

.review:hover::before {
  color: var(--color-ink-muted);
}

.review > * {
  position: relative;
}

.review__head {
  display: flex;
  align-items: center;
  gap: var(--space-xs);
}

.review__avatar {
  flex: none;
  width: 48px;
  height: 48px;
  border-radius: 50%;
  object-fit: cover;
}

.review__meta {
  display: grid;
  gap: 2px;
}

.review__name {
  font-family: var(--font-secondary);
  font-size: var(--text-md);
  font-weight: 700;
  line-height: var(--leading-snug);
  color: var(--color-ink);
}

.review__stars {
  color: var(--color-ink);
  font-size: var(--text-sm);
  letter-spacing: 0.12em;
  line-height: 1;
}

.review__text {
  color: var(--color-ink-soft);
}

/* --------------------------------------------------------------------------
   Testimonial stack
   A staggered deck of review cards; click one, or the arrows, to bring it to
   the front. main.js owns the geometry — card width/height, the transform,
   and the stage height all come from the viewport there, because the card has
   to fit the LONGEST review rather than a breakpoint's idea of a card. Only
   the chrome is here. See the notes above initTestimonialStack().

   Nothing in here clamps or truncates the quote. A cut-off review is worse
   than a big card, and these are the client's real words.

   Progressive enhancement: without JS the markup stays a plain, fully
   readable stacked list — no deck, but never a missing or clipped review. */

.testimonial-stack__list {
  display: grid;
  gap: var(--space-lg);
}

.testimonial-stack.is-ready .testimonial-stack__viewport {
  position: relative;
  overflow: hidden;
}

.testimonial-stack.is-ready .testimonial-stack__list {
  display: block;
  gap: 0;
}

/* ONE chamfer, top-right, and the notch below redraws that cut as a rule so
   the corner reads as a deliberate fold rather than a clipping accident. The
   asymmetry is the point: chamfering both top corners made the card a label,
   cutting a single corner makes it a turned page. Card ink is a 42% tint
   rather than solid black — seven of these overlap in the fan, and seven
   full-strength rules stack into a thicket. */
.testimonial-card {
  position: relative;
  display: grid;
  align-content: start;
  padding: 22px 20px;
  background: var(--color-paper);
  border: 2px solid var(--color-card-rule);
  clip-path: polygon(
    calc(100% - 34px) 0%, 100% 34px,
    100% 100%, 0 100%, 0 0
  );
}

@media (min-width: 640px) {
  .testimonial-card {
    padding: 34px;
    clip-path: polygon(
      calc(100% - 50px) 0%, 100% 50px,
      100% 100%, 0 100%, 0 0
    );
  }
}

/* Every card in the fan is set to the height of the LONGEST review, so the
   rows are laid out explicitly: avatar, stars, then the quote taking whatever
   is left, then the attribution on the floor. That gives every card a common
   baseline for the name without leaving a short review stranded at the top of
   a tall card — the quote centres in the space it has. */
.testimonial-stack.is-ready .testimonial-card {
  position: absolute;
  top: 50%;
  left: 50%;
  display: grid;
  grid-template-rows: auto auto 1fr auto;
  align-content: stretch;
  overflow: hidden;
  cursor: pointer;
  z-index: 0;
  will-change: transform;
  transition: transform 600ms var(--ease-glide),
    opacity 600ms var(--ease-glide),
    background-color 500ms var(--ease-glide),
    color 500ms var(--ease-glide),
    border-color 500ms var(--ease-glide);
}

/* Parked outside the drawable fan — see visibleDepth() in main.js. Faded
   rather than display:none so it can slide in, and inert so a click cannot
   land on a card nobody can see. */
.testimonial-stack.is-ready .testimonial-card.is-offstage {
  pointer-events: none;
}

.testimonial-stack.is-ready .testimonial-card__quote {
  align-self: center;
}

/* Hovering a card in the fan darkens its rule — the only affordance the deck
   needs, since clicking anywhere on one walks it to the centre. */
.testimonial-stack.is-ready .testimonial-card:not(.is-active):hover {
  border-color: var(--color-ink);
}

/* The centred card reads as a contrast block — the site's stand-in for
   elevation everywhere else. Deliberately no box-shadow, and not for the
   usual reason: clip-path clips an element's shadow along with its box, so
   the hard offset block this card would otherwise carry cannot paint at all.
   The ink/paper flip is doing the whole job of bringing the card forward,
   which on a monochrome page it can. Anything else needs a wrapper element
   outside the clip, and the deck does not need one. */
.testimonial-card.is-active {
  background: var(--color-ink);
  border-color: var(--color-ink);
  color: var(--color-paper);
}

.testimonial-card.is-active .testimonial-card__quote,
.testimonial-card.is-active .testimonial-card__name {
  color: var(--color-paper);
}

.testimonial-card.is-active .testimonial-card__role {
  color: rgba(255, 255, 255, 0.72);
}

/* The rule across the top-right chamfer, matching the 2px border it
   continues. A 34px cut needs 34 * √2 of line, a 50px cut needs 50 * √2 —
   the width and the clip-path corner must always move together. */
.testimonial-card__notch {
  position: absolute;
  display: block;
  top: 32px;
  right: -2px;
  width: 48.1px;
  height: 2px;
  background: var(--color-card-rule);
  transform: rotate(45deg);
  transform-origin: top right;
}

@media (min-width: 640px) {
  .testimonial-card__notch {
    top: 48px;
    width: 70.7px;
  }
}

.testimonial-card.is-active .testimonial-card__notch {
  background: rgba(255, 255, 255, 0.42);
}

/* A portrait tile, not a round avatar, with a hard offset square behind it —
   the same printed-registration idea as the centre card's block shadow, at
   3px. The photograph is the client's real Google avatar; the tile is what
   gives a 48px crop enough presence to open the card. */
.testimonial-card__avatar {
  width: 48px;
  height: 56px;
  margin-bottom: 14px;
  object-fit: cover;
  object-position: top;
  box-shadow: 3px 3px 0 var(--color-line);
}

@media (min-width: 640px) {
  .testimonial-card__avatar {
    margin-bottom: 18px;
  }
}

.testimonial-card.is-active .testimonial-card__avatar {
  box-shadow: 3px 3px 0 rgba(255, 255, 255, 0.22);
}

.testimonial-card__stars {
  margin-bottom: 8px;
  color: currentColor;
  font-size: 0.75rem;
  letter-spacing: 2px;
  line-height: 1;
}

@media (min-width: 640px) {
  .testimonial-card__stars {
    margin-bottom: 10px;
    font-size: 0.8125rem;
  }
}

/* Set a size up from the site's other small copy and leaded tight at 1.34.
   The quote is the card — it should fill it, not sit in it. */
.testimonial-card__quote {
  font-family: var(--font-primary);
  font-size: 0.9375rem;
  line-height: 1.36;
  color: var(--color-ink);
}

@media (min-width: 640px) {
  .testimonial-card__quote {
    font-size: 1.125rem;
    line-height: 1.34;
  }
}

.testimonial-card__name {
  display: block;
  font-family: var(--font-secondary);
  font-weight: 700;
  font-size: 0.78125rem;
  line-height: 1.4;
  color: var(--color-ink);
}

@media (min-width: 640px) {
  .testimonial-card__name {
    font-size: 0.8125rem;
  }
}

/* Lekker sets this line in italic; this site ships no italic face, so the
   attribution is separated by case and tracking instead of by slant. */
.testimonial-card__role {
  display: block;
  margin-top: 2px;
  font-size: 0.71875rem;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--color-ink-muted);
}

.testimonial-stack__controls {
  display: flex;
  justify-content: center;
  gap: var(--space-sm);
  margin-top: var(--space-md);
}

/* Square, and rendered in the card's own 2px rule, so the controls read as
   part of the deck rather than as generic carousel chrome. */
.testimonial-stack__btn {
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
  border: 2px solid var(--color-card-rule);
  color: var(--color-ink);
  transition: background-color 350ms var(--ease-glide),
    color 350ms var(--ease-glide),
    border-color 350ms var(--ease-glide);
}

@media (min-width: 640px) {
  .testimonial-stack__btn {
    width: 54px;
    height: 54px;
  }
}

.testimonial-stack__btn:hover,
.testimonial-stack__btn:focus-visible {
  background: var(--color-ink);
  border-color: var(--color-ink);
  color: var(--color-paper);
}

.testimonial-stack__btn svg {
  width: 22px;
  height: 22px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}


/* --------------------------------------------------------------------------
   Galleries
   -------------------------------------------------------------------------- */

/* Homepage teaser: a fixed grid of equal cells, so the block always closes on
   a straight edge instead of leaving one ragged row. */
.gallery-grid {
  display: grid;
  gap: var(--gap-media);
  grid-template-columns: repeat(2, 1fr);
}

@media (min-width: 640px) {
  .gallery-grid {
    grid-template-columns: repeat(3, 1fr);
  }
}

.gallery-grid .gallery-item {
  aspect-ratio: 4 / 5;
}

/* Full gallery: true masonry where supported, columns everywhere else.
   Hairline 2px gutters — the photographs butt together like contact sheets. */
.gallery-masonry {
  columns: 1;
  column-gap: 2px;
}

@media (min-width: 640px) {
  .gallery-masonry {
    columns: 2;
  }
}

@media (min-width: 1080px) {
  .gallery-masonry {
    columns: 3;
  }
}

.gallery-masonry .gallery-item {
  margin-bottom: 2px;
  break-inside: avoid;
}

/* Edge-to-edge strip: a full-bleed run of equal cells split by 2px gutters,
   closing on dead-straight edges. Sits inside a .section--flush with no
   .container. Six items resolve 2×3 → 3×2 → 6×1 across the breakpoints. */
.gallery-strip {
  display: grid;
  gap: 2px;
  grid-template-columns: repeat(2, 1fr);
}

@media (min-width: 640px) {
  .gallery-strip {
    grid-template-columns: repeat(3, 1fr);
  }
}

@media (min-width: 1080px) {
  .gallery-strip {
    grid-template-columns: repeat(6, 1fr);
  }
}

.gallery-strip .gallery-item {
  aspect-ratio: 3 / 4;
}

.gallery-item {
  position: relative;
  display: block;
  width: 100%;
  padding: 0;
  overflow: hidden;
  background: var(--color-paper-warm);
  cursor: zoom-in;
}

/* Keep the focus ring visible when items sit flush against each other or the
   viewport edge — draw it just inside the tile instead of outside. */
.gallery-item:focus-visible {
  outline-offset: -3px;
}

.gallery-item img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* only bites in .gallery-grid / .gallery-strip, which give the tile a fixed
     ratio; the masonry shows whole frames. See .package__media img. */
  object-position: var(--focus, 50% 50%);
  transition: transform var(--duration-slow) var(--ease-out),
    filter var(--duration-slow) var(--ease-out);
}

.gallery-item:hover img,
.gallery-item:focus-visible img {
  transform: scale(1.04);
  filter: brightness(0.92);
}

/* --------------------------------------------------------------------------
   Hero carousel (homepage)
   -------------------------------------------------------------------------- */

/* The carousel fills the screen below the header and every slide covers it —
   same contract as .page-hero in layout.css, including --hero-focus and the
   graded scrim, so the two heroes behave identically on every screen. */
.hero {
  --hero-focus: 50% 50%;
  position: relative;
  display: grid;
  place-items: center;
  /* stated width — see the note on .page-hero in layout.css */
  width: 100%;
  /* the screen minus the sticky header; svh with a vh fallback, both explained
     on .page-hero */
  height: calc(100vh - var(--header-height));
  height: calc(100svh - var(--header-height));
  /* The height is definite, so anything the headline block needs beyond it
     would be clipped rather than grow the box. The floor is set by the copy
     (measured ~515px at the narrowest) plus the dots below it, and only bites
     on landscape phones. */
  min-height: 35rem;
  overflow: hidden;
  isolation: isolate;
  background: var(--color-ink);
}

/* The hero stacks with positive z-indexes inside its own isolated context —
   slides (1), scrim (2), headline (3), dots (4). Negative z-index is avoided
   deliberately, and so is the blurred fill layer each slide used to carry: see
   the note on .page-hero__media in layout.css for what that layer cost. */
.hero__slides {
  position: absolute;
  inset: 0;
  z-index: 1;
}

.hero__slide {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 1.2s var(--ease-in-out);
}

.hero__slide.is-active {
  opacity: 1;
}

.hero__slide > img {
  position: relative;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: var(--hero-focus);
}

/* Slow Ken Burns push on the slide that is showing. It runs on the photograph
   now rather than on a blurred layer beneath it: the zoom eats ~5% of the
   frame, and a hero that already crops to fill the screen has that to give.
   The scroll drift below takes the slide box, not the image, so the two
   transforms never land on the same element. */
@media (prefers-reduced-motion: no-preference) {
  .hero__slide > img {
    transform: scale(1.02);
    transition: transform 9s var(--ease-out);
  }

  .hero__slide.is-active > img {
    transform: scale(1.07);
  }
}

/* Same grade as .page-hero, shifted for this hero's copy: the pocket is wider
   (an eyebrow, a two-line headline and two buttons, not one word) and the
   bottom grade is deeper because the dots sit down there. Light, for the
   reason given over there — the copy carries its own wash. */
.hero::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  background:
    radial-gradient(
      76% 58% at 50% 48%,
      rgba(0, 0, 0, 0.34) 0%,
      rgba(0, 0, 0, 0.18) 58%,
      rgba(0, 0, 0, 0) 100%
    ),
    linear-gradient(
      180deg,
      rgba(0, 0, 0, 0.36) 0%,
      rgba(0, 0, 0, 0.06) 24%,
      rgba(0, 0, 0, 0.08) 58%,
      rgba(0, 0, 0, 0.5) 100%
    );
}

/* The headline block over the carousel. The h1 lives here — sentence case at
   display scale, the largest type on the site, against uppercase page titles
   everywhere else. */
.hero__inner {
  position: relative;
  z-index: 3;
  display: grid;
  gap: var(--space-md);
  justify-items: center;
  padding: var(--space-2xl) var(--container-pad);
  text-align: center;
  color: var(--color-paper);
}

/* The ghost button is white type on the photograph as well; its solid
   neighbour brings its own background and must not get the halo, or the dark
   label on it would smear. */
.hero__inner .btn--ghost {
  text-shadow: var(--copy-halo);
}

.hero__eyebrow {
  font-family: var(--font-secondary);
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-widest);
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.92);
  text-shadow: var(--copy-halo);
}

.hero__title {
  color: var(--color-paper);
  text-shadow: var(--copy-halo);
  font-size: var(--step-display);
  font-weight: var(--weight-display);
  line-height: 1.05;
  letter-spacing: var(--tracking-tight);
}

/* Load choreography for the headline block, split across two owners on
   purpose: the title rises out of its own clip in CSS (title-rise,
   layout.css) so it plays with JS disabled and can never be stranded hidden,
   and the eyebrow and actions below it are staggered in by initMotion().
   The `reveal-up` rules that used to sit here named a keyframe that left with
   the old fade-up/settle/wipe system, so they had stopped animating anything
   long before they were removed. Do not put one back on an element GSAP is
   already tweening: a CSS animation outranks GSAP's inline transform while it
   runs and then hands the property back mid-tween, which is visible as the
   element jumping and moving twice. That bug lived on the hero titles. */

/* Scroll drift as the hero leaves the viewport. It takes the slide box rather
   than the image inside it, which is already carrying the Ken Burns scale —
   one transform per element. The named timeline sits on .hero, so every slide
   in the stack binds to the same one. Same gates and caveats as the parallax
   block in layout.css. */
@media (prefers-reduced-motion: no-preference) and (min-width: 860px) {
  @supports (animation-timeline: view()) {
    .hero {
      view-timeline: --hero block;
    }

    .hero__slide {
      /* bleed past the section top and bottom, so the drift has somewhere to
         travel without opening a gap at either edge */
      top: calc(-1 * var(--parallax-shift));
      bottom: calc(-1 * var(--parallax-shift));
      height: auto;
      will-change: transform;
      animation: parallax-exit linear both;
      animation-timeline: --hero;
      animation-range: exit;
    }
  }
}

.hero__dots {
  position: absolute;
  left: 50%;
  bottom: clamp(1.25rem, 3vh, 2.5rem);
  transform: translateX(-50%);
  display: flex;
  gap: var(--space-2xs);
  z-index: 4;
}

.hero__dot {
  width: 36px;
  height: 44px;
  padding: 21px 0;
  background: none;
  border: 0;
  cursor: pointer;
}

.hero__dot::before {
  content: "";
  display: block;
  width: 100%;
  height: 2px;
  background: rgba(255, 255, 255, 0.4);
  transition: background-color var(--duration-base) var(--ease-out);
}

.hero__dot:hover::before {
  background: rgba(255, 255, 255, 0.7);
}

.hero__dot[aria-current="true"]::before {
  background: var(--color-paper);
}

/* --------------------------------------------------------------------------
   Lightbox
   -------------------------------------------------------------------------- */

.lightbox {
  position: fixed;
  inset: 0;
  z-index: var(--z-lightbox);
  display: grid;
  grid-template-rows: auto 1fr;
  background: var(--color-overlay-deep);
  opacity: 0;
  visibility: hidden;
  /* visibility is switched with a 0s transition DELAYED to the end of the
     fade, not interpolated over it. Closing therefore keeps the dialog
     paintable until the fade finishes, while OPENING makes it visible in the
     same style recalc as .is-open — which is what initLightbox() relies on: a
     visibility: hidden element cannot take focus, so with visibility on the
     320ms transition the focus() call in open() silently did nothing and the
     viewer opened with focus still on the tile behind it. */
  transition: opacity var(--duration-base) var(--ease-out),
    visibility 0s linear var(--duration-base);
}

.lightbox.is-open {
  opacity: 1;
  visibility: visible;
  transition: opacity var(--duration-base) var(--ease-out), visibility 0s linear 0s;
}

.lightbox__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-sm);
  padding: var(--space-sm) var(--container-pad);
  color: var(--color-paper);
  font-size: var(--text-sm);
  letter-spacing: var(--tracking-wide);
}

.lightbox__stage {
  position: relative;
  display: grid;
  place-items: center;
  /* The photograph reads these back as its own insets, so the two cannot drift. */
  --lb-pad-x: clamp(0.5rem, 4vw, 4.5rem);
  --lb-pad-b: clamp(1rem, 4vh, 3rem);
  padding: 0 var(--lb-pad-x) var(--lb-pad-b);
  min-height: 0;
}

/* Absolutely positioned, NOT a centred grid item. As a grid item its
   `max-height: 100%` silently resolved to nothing: the stage's height comes from
   the `1fr` row above it, which Chrome treats as indefinite for percentage
   resolution, so only `max-width` ever bit. Every photograph was therefore laid
   out at its intrinsic height — a 1440x2160 portrait came out 1923px tall in a
   725px stage — overflowed, and the viewer showed a centred crop of the middle
   of the frame. That is the "zoomed in" lightbox. Percentages on an absolutely
   positioned box resolve against its containing block instead, which
   `position: relative` on the stage makes definite. `margin: auto` against the
   four insets does the centring the grid used to do, and width/height stay
   `auto` so the box keeps the photograph's own ratio and hugs it — the stage
   stays clickable around it for close-on-backdrop, and the shadow still traces
   the photograph rather than a letterboxed rectangle. */
.lightbox__img {
  position: absolute;
  inset: 0 var(--lb-pad-x) var(--lb-pad-b);
  margin: auto;
  width: auto;
  height: auto;
  max-width: calc(100% - 2 * var(--lb-pad-x));
  max-height: calc(100% - var(--lb-pad-b));
  object-fit: contain;
  box-shadow: var(--shadow-lg);
  transform: scale(0.97);
  transition: transform var(--duration-base) var(--ease-out);
}

.lightbox.is-open .lightbox__img {
  transform: scale(1);
}

.lightbox__btn {
  display: grid;
  place-items: center;
  width: 48px;
  height: 48px;
  border: 1px solid rgba(255, 255, 255, 0.28);
  border-radius: 50%;
  color: var(--color-paper);
  background: rgba(0, 0, 0, 0.35);
  transition: background-color var(--duration-fast) var(--ease-out),
    border-color var(--duration-fast) var(--ease-out);
}

.lightbox__btn:hover,
.lightbox__btn:focus-visible {
  background: rgba(255, 255, 255, 0.16);
  border-color: var(--color-paper);
}

.lightbox__btn svg {
  width: 20px;
  height: 20px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.lightbox__nav {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
}

.lightbox__nav--prev { left: clamp(0.25rem, 1.5vw, 1rem); }
.lightbox__nav--next { right: clamp(0.25rem, 1.5vw, 1rem); }

/* --------------------------------------------------------------------------
   Contact — direct-action panels replacing the old form

   Two panels, so the grid goes straight from one column to two. An auto-fit
   grid would leave a stranded card at middling widths. The count is authored
   here rather than left to auto-fit — it was three before the email card was
   dropped, so re-check this rule if a card is ever added back.

   The panels butt together on a SHARED hairline (negative margin, not a grid
   gap with a coloured track): the reveal in initMotion() fades each card in
   from transparent, and a 1px track painted on the grid itself would show
   through as a bare grey cross while they arrive.
   -------------------------------------------------------------------------- */

.contact-grid {
  display: grid;
  grid-template-columns: 1fr;
}

.contact-card + .contact-card {
  margin-top: -1px;
}

@media (min-width: 860px) {
  .contact-grid {
    grid-template-columns: repeat(2, 1fr);
  }

  .contact-card + .contact-card {
    margin-top: 0;
    margin-left: -1px;
  }
}

/* Flex column rather than grid: the cue row is pinned to the bottom edge with
   margin-top:auto, so the panel keeps its height whatever the copy does.
   --cc-fill / --cc-on-fill are the hover pair, declared HERE and not on the
   hover rule, because the wipe is a pseudo-element that inherits from the card
   — remapping --color-ink on hover would repaint the wipe in its own new
   colour. The panels live in a .section--dark, so the fill is white. */
.contact-card {
  --cc-fill: #ffffff;
  --cc-on-fill: #0a0a0a;

  position: relative;
  isolation: isolate;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  gap: var(--space-2xs);
  min-height: clamp(19rem, 13rem + 20vw, 27rem);
  padding: clamp(1.75rem, 1.1rem + 2.6vw, 3rem);
  border: 1px solid var(--color-line);
  background: var(--color-paper);
  color: inherit;
  text-decoration: none;
  transition: border-color var(--duration-slow) var(--ease-glide),
    color var(--duration-base) var(--ease-out);
}

/* The wipe. An element's own background paints BELOW its negative z-index
   children, so this sits over the card surface and under every child — no
   z-index needed on the copy. isolation:isolate keeps the -1 inside the card
   instead of letting it slide behind the section. */
.contact-card::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  background: var(--cc-fill);
  transform: scaleY(0);
  transform-origin: 50% 100%;
  transition: transform var(--duration-slow) var(--ease-glide);
}

/* Full inversion on hover, wiped up from the bottom edge rather than swapped.
   Placeholder cards (no working link yet) keep their dashed resting state. */
.contact-card:not([data-placeholder="true"]):hover::before,
.contact-card:not([data-placeholder="true"]):focus-visible::before {
  transform: scaleY(1);
}

.contact-card:not([data-placeholder="true"]):hover,
.contact-card:not([data-placeholder="true"]):focus-visible {
  border-color: var(--cc-fill);
  color: var(--cc-on-fill);
  --color-ink: #0a0a0a;
  --color-ink-muted: #55585c;
  --color-line: rgba(10, 10, 10, 0.28);
}

/* The ghost index numeral — the same graphic device as .index-row__num, set
   as an outline here so it reads as watermark rather than as a second
   headline competing with the number underneath it. Outlined text falls back
   to a solid, quiet fill where -webkit-text-stroke is unsupported. */
.contact-card__num {
  position: absolute;
  top: clamp(0.75rem, 0.3rem + 1.4vw, 1.5rem);
  right: clamp(1rem, 0.5rem + 1.6vw, 1.75rem);
  font-family: var(--font-primary);
  font-size: clamp(4.5rem, 2.5rem + 8vw, 9rem);
  font-weight: 400;
  line-height: 0.8;
  color: var(--color-line);
  pointer-events: none;
  transition: color var(--duration-base) var(--ease-out),
    -webkit-text-stroke-color var(--duration-base) var(--ease-out);
}

@supports (-webkit-text-stroke: 1px #000) {
  .contact-card__num {
    color: transparent;
    -webkit-text-stroke: 1px var(--color-line);
  }
}

/* The stroke has to be nudged darker once the panel is white: the remapped
   --color-line above is tuned for hairlines, and at that alpha a 9rem outline
   all but vanishes against the fill. */
.contact-card:not([data-placeholder="true"]):hover .contact-card__num,
.contact-card:not([data-placeholder="true"]):focus-visible .contact-card__num {
  color: rgba(10, 10, 10, 0.38);
  -webkit-text-stroke-color: rgba(10, 10, 10, 0.38);
}

.contact-card__icon {
  display: grid;
  place-items: center;
  flex: none;
  width: 56px;
  height: 56px;
  margin-bottom: var(--space-md);
  border: 1px solid var(--color-line);
  border-radius: 50%;
  color: var(--color-ink);
  transition: background-color var(--duration-base) var(--ease-out),
    border-color var(--duration-base) var(--ease-out),
    color var(--duration-base) var(--ease-out);
}

/* The one solid shape on the panel once the wipe has landed — the icon flips
   the other way, so the card never goes flat white. */
.contact-card:not([data-placeholder="true"]):hover .contact-card__icon,
.contact-card:not([data-placeholder="true"]):focus-visible .contact-card__icon {
  border-color: var(--cc-on-fill);
  background: var(--cc-on-fill);
  color: var(--cc-fill);
}

.contact-card__icon svg {
  width: 24px;
  height: 24px;
  fill: currentColor;
}

.contact-card__label {
  font-family: var(--font-secondary);
  font-size: var(--text-sm);
  font-weight: 700;
  letter-spacing: var(--tracking-widest);
  text-transform: uppercase;
  color: var(--color-ink-muted);
}

.contact-card__value {
  font-family: var(--font-primary);
  font-size: var(--step-h3);
  font-weight: 400;
  line-height: var(--leading-tight);
  letter-spacing: var(--tracking-tight);
  color: var(--color-ink);
  overflow-wrap: anywhere;
}

.contact-card__note {
  font-size: var(--text-sm);
  line-height: var(--leading-snug);
  color: var(--color-ink-muted);
}

/* Pinned to the bottom of the panel, above a hairline — the panel reads as a
   door rather than as a paragraph with a border. */
.contact-card__cue {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-sm);
  margin-top: auto;
  padding-top: var(--space-md);
  border-top: 1px solid var(--color-line);
  font-family: var(--font-secondary);
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-widest);
  text-transform: uppercase;
  color: var(--color-ink);
  transition: border-color var(--duration-base) var(--ease-out),
    color var(--duration-base) var(--ease-out);
}

.contact-card__cue svg {
  width: 26px;
  height: 12px;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.25;
  transition: transform var(--duration-base) var(--ease-out);
}

.contact-card:not([data-placeholder="true"]):hover .contact-card__cue svg,
.contact-card:not([data-placeholder="true"]):focus-visible .contact-card__cue svg {
  transform: translateX(6px);
}

/* Shown when the contact details in site-config.js are still placeholders */
.contact-card[data-placeholder="true"] {
  border-style: dashed;
  opacity: 0.65;
}

/* --------------------------------------------------------------------------
   Team member rows (about page)
   -------------------------------------------------------------------------- */

.team-member {
  display: grid;
  gap: var(--gap-split);
  align-items: center;
}

.team-member + .team-member {
  margin-top: var(--section-gap);
}

/* height: auto is load-bearing. The markup carries width/height attributes, and
   those become a used height that aspect-ratio cannot override — the box stayed
   at the file's full pixel height and cover cropped inwards to a face. */
.team-member__media img {
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 5;
  object-fit: cover;
  object-position: var(--focus, 50% 50%);
}

.team-member__body > * + * {
  margin-top: var(--space-sm);
}

.team-member__name {
  font-size: var(--step-h2);
  letter-spacing: var(--tracking-wide);
}

@media (min-width: 860px) {
  .team-member {
    grid-template-columns: 1fr 1fr;
  }

  .team-member--reverse .team-member__media {
    order: 2;
  }
}

/* Trio of photographs under the About intro */
.photo-trio {
  display: grid;
  gap: var(--gap-media);
  grid-template-columns: 1fr;
}

@media (min-width: 640px) {
  .photo-trio {
    grid-template-columns: repeat(3, 1fr);
  }
}

/* height: auto for the same reason as .team-member__media img above */
.photo-trio img {
  width: 100%;
  height: auto;
  aspect-ratio: 2 / 3;
  object-fit: cover;
  object-position: var(--focus, 50% 50%);
}

/* --------------------------------------------------------------------------
   Photography hover language
   One rule for the whole site: photographs zoom slowly and darken a touch
   (the gallery treatment); non-interactive surfaces never lift. The wrappers
   take overflow:hidden so the zoom crops instead of spilling. The trio images
   have no wrapper, so they only darken.
   -------------------------------------------------------------------------- */

.split__media,
.team-member__media {
  overflow: hidden;
}

.split__media img,
.team-member__media img {
  transition: transform var(--duration-slow) var(--ease-out),
    filter var(--duration-slow) var(--ease-out);
}

.split__media:hover img,
.team-member__media:hover img {
  transform: scale(1.03);
  filter: brightness(0.95);
}

.photo-trio img {
  transition: filter var(--duration-slow) var(--ease-out);
}

.photo-trio img:hover {
  filter: brightness(0.92);
}

/* --------------------------------------------------------------------------
   Coverflow gallery
   A ring of photographs raked back in 3D around a centre card, driven by
   initCoverflow() in main.js. Everything here is derived from --cf-card, so
   the whole rake scales by changing one length.

   TWO LAYOUTS LIVE IN THIS BLOCK, and which one you get is the point.
   The rules below the .is-ready line are the carousel. The rules above it are
   a plain horizontal scroll-snap strip, and that is what renders with no JS,
   before main.js runs, or if initCoverflow() bails out (fewer than three
   photographs). Same markup, same photographs, no empty box — the site's
   standing rule that nothing may be hidden in CSS waiting for a script to
   arrive. `is-ready` is added at the END of initCoverflow(), after the cards
   have been measured and the first frame painted, so the swap never shows a
   half-built deck.

   No box-shadow on the cards, deliberately: the chrome here is flat (see the
   elevation note in base.css) and this component gets its depth from the rake
   and the fade, which is more depth than a drop shadow was ever going to buy.
   -------------------------------------------------------------------------- */

/* The one length everything else is derived from — pitch, recession,
   perspective and the track's height all scale off it. Bigger than the React
   original's clamp(148px, 22vw, 260px): that was sized for album art, and
   these are photographs the business is selling.

   STEPPED AT THE CANON BREAKPOINTS, NOT CLAMPED. Every other size on the site
   is a clamp() and they all behave; a viewport-relative width on THIS element
   does not — it is measured back as the full width of the frame, which sends
   initCoverflow()'s pitch out by a factor of four and flings every neighbour
   card off the edge. A literal px width on the same rule resolves correctly.
   The steps below are the same four widths the clamp resolved to anyway, so
   nothing about the design changes; it is just arithmetic the layout engine
   and getComputedStyle agree on. */
.coverflow {
  --cf-card: 200px;
  position: relative;
}

@media (min-width: 640px) {
  .coverflow {
    --cf-card: 240px;
  }
}

@media (min-width: 860px) {
  .coverflow {
    --cf-card: 290px;
  }
}

@media (min-width: 1080px) {
  .coverflow {
    --cf-card: 340px;
  }
}

/* Fallback strip ---------------------------------------------------------- */

.coverflow__frame {
  overflow-x: auto;
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
  -webkit-overflow-scrolling: touch;
  /* Clearance under the section rule, and in the deck below, room for the
     outer cards — they sit lower in the frame than the centre one once they
     have rotated. Both layouts want it, so it lives here. */
  padding-block: var(--space-md);
}

.coverflow__track {
  display: flex;
  gap: 2px;
}

.coverflow__card {
  flex: 0 0 var(--cf-card);
  aspect-ratio: 3 / 4;
  scroll-snap-align: center;
}

/* The deck ---------------------------------------------------------------- */

.coverflow.is-ready .coverflow__frame {
  /* THE FALLBACK'S SCROLLING MUST BE TURNED OFF HERE, NOT JUST HIDDEN.
     `overflow: hidden` still leaves a scroll container — it only takes the bars
     away — and the strip's `scroll-snap-type: x mandatory` is still in force,
     while every card still carries `scroll-snap-align: center` below. In the
     deck those cards sit at their transformed positions, hundreds of pixels
     either side of the frame, so they are snap targets in all the wrong
     places. Mandatory snapping means the browser has to come to rest on one of
     them: it scrolls the frame, and the whole deck slides off its centre line
     with the selected card no longer under the dots. Focusing a card does the
     same thing on its own, because a browser scrolls a hidden-overflow
     ancestor to reveal the focused element, and every card is a <button>.
     `overflow: clip` is not a scroll container at all, so none of it can
     happen; `hidden` stays above it for anything that does not know `clip`. */
  overflow: hidden;
  overflow: clip;
  scroll-snap-type: none;
  /* A smaller multiple is a wider lens. Three is enough rake to read as depth
     without the far cards distorting. */
  perspective: calc(var(--cf-card) * 3);
  /* Horizontal drag is ours; the page keeps vertical scrolling. */
  touch-action: pan-y;
  cursor: grab;
  outline: none;
}

.coverflow.is-ready .coverflow__frame:active {
  cursor: grabbing;
}

/* The frame takes the arrow keys, so it must show a focus ring — it is the
   keyboard route to every photograph that is not the centre one. */
.coverflow.is-ready .coverflow__frame:focus-visible {
  outline: 2px solid var(--color-ink);
  outline-offset: -3px;
}

.coverflow.is-ready .coverflow__track {
  position: relative;
  display: block;
  height: calc(var(--cf-card) * 4 / 3);
  transform-style: preserve-3d;
  user-select: none;
}

.coverflow.is-ready .coverflow__card {
  position: absolute;
  left: 50%;
  top: 0;
  /* Height first, width from the ratio. The track is exactly one card tall
     (--cf-card * 4/3), so `height: 100%` plus the 3/4 ratio lands the card on
     --cf-card without the width ever naming it. That matters: `width:
     var(--cf-card)` here does not resolve — the card measures back as the full
     frame, which is `.gallery-item { width: 100% }` showing through — and a
     wrong measurement is worse than a wrong style, because initCoverflow()
     reads this box to derive the pitch for every other card. */
  height: 100%;
  width: auto;
  /* Not a snap target any more — see the frame's overflow note above. */
  scroll-snap-align: none;
  /* transform, opacity and z-index are all written inline by paint(). */
  will-change: transform;
  cursor: pointer;
}

.coverflow.is-ready .coverflow__card img {
  pointer-events: none; /* the drag belongs to the frame, not the browser's image drag */
}

/* .gallery-item's zoom-on-hover belongs to the card being looked at. On a
   card raked 40 degrees away it is just movement in the corner of the eye. */
.coverflow.is-ready .coverflow__card:not(.is-active):hover img,
.coverflow.is-ready .coverflow__card:not(.is-active):focus-visible img {
  transform: none;
  filter: none;
}

.coverflow.is-ready .coverflow__card.is-active {
  cursor: zoom-in;
}

/* Controls ---------------------------------------------------------------- */

/* The arrows and dots are markup, so they exist before initCoverflow() reveals
   them — and a class carrying `display: flex` outranks the UA rule that makes
   [hidden] disappear. Without this, the no-JS fallback ships two dead buttons
   and an empty dot rail under a strip that scrolls perfectly well by itself. */
.coverflow__controls[hidden] {
  display: none;
}

.coverflow__controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-md);
  margin-top: var(--space-md);
}

.coverflow__arrow {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 1px solid var(--color-card-rule);
  background: transparent;
  color: var(--color-ink);
  cursor: pointer;
  transition: background var(--duration-fast) var(--ease-out),
    color var(--duration-fast) var(--ease-out);
}

.coverflow__arrow:hover {
  background: var(--color-ink);
  color: var(--color-paper);
}

.coverflow__arrow svg {
  width: 18px;
  height: 18px;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
}

.coverflow__dots {
  display: flex;
  align-items: center;
  /* no gap: each dot button is already a 24px target and they must not overlap */
  gap: 0;
}

/* A 7px dot is the mark, not the target. The button around it is a full 24px
   square — the WCAG 2.5.8 minimum — and the dot itself is drawn by ::before,
   the same way .hero__dot draws its bar. The rail drops its gap to nothing so
   the 24px targets sit edge to edge and never overlap. */
.coverflow__dot {
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
}

.coverflow__dot::before {
  content: "";
  display: block;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--color-ink);
  opacity: 0.25;
  transition: opacity var(--duration-fast) var(--ease-out);
}

.coverflow__dot[aria-current="true"]::before {
  opacity: 1;
}

