/* =========================================================================
   MCM 2001 — components.css
   Header, burger, menu panel, work card, footer.
   Class names follow docs/superpowers/specs/markup-contract.md exactly.
   ========================================================================= */

/* -------------------------------------------------------------------------
   1. Skip link
   ---------------------------------------------------------------------- */

.skip-link {
  position: fixed;
  top: var(--space-2xs);
  left: var(--space-2xs);
  z-index: 200;
  padding: var(--space-2xs) var(--space-s);
  background: var(--ink);
  color: var(--paper);
  font-size: var(--step--1);
  letter-spacing: .06em;
  text-transform: uppercase;
  transform: translateY(-200%);
}

.skip-link:focus-visible {
  transform: translateY(0);
  outline-offset: 2px;
}

/* -------------------------------------------------------------------------
   2. Site header
   Fixed and transparent. On scroll main.js reads the computed colour of the
   section under the header and gives it to the header, so the mark stays
   legible over photography, over white and over the blue footer. The palette
   therefore lives here, on the sections. The colour below is the resting
   state and the no-JS fallback, white for the hero at the top of the page.
   ---------------------------------------------------------------------- */

.site-header {
  position: fixed;
  inset-block-start: 0;
  inset-inline: 0;
  z-index: 100;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-s);
  padding: var(--space-m) var(--page-margin);
  color: var(--paper);
  /* No colour transition here. main.js tweens this exact property with GSAP as
     each section scrolls past, and a CSS transition on the same property would
     animate toward every frame GSAP writes, turning a crisp 0.3s change into a
     muddy double ease. GSAP owns colour on this element. */
  /* The bar itself must not swallow clicks on the photograph beneath it. */
  pointer-events: none;
}

.site-header > * {
  pointer-events: auto;
}

/* While the panel is open the header always reads white on the blue field.
   menu.js puts .is-menu-open on the root element. The adapter writes its
   colour inline, so this rule has to outrank an inline style. */
.is-menu-open .site-header,
.site-header:has(.burger[aria-expanded="true"]) {
  color: var(--paper) !important;
}

.site-header__logo {
  display: block;
  line-height: 0;
}

/* The mark is brand blue rather than currentColor, so the logo keeps the
   company's own colour instead of inheriting whatever the colour adapter has
   given the MENU label for the section underneath.

   The paths have to be named explicitly. Each one carries a fill="currentColor"
   presentation attribute in the markup, and that attribute beats a fill
   inherited from the parent svg, so setting the colour on the svg alone leaves
   the paths following the header colour. A CSS rule does outrank a presentation
   attribute, which is why these selectors reach the paths directly. */
.site-header__mark {
  width: clamp(5.5rem, 8vw, 10rem);
  height: auto;
}

.site-header__mark,
.site-header__mark path {
  fill: var(--blue-brand);
}

/* The two places blue on blue would disappear. A light header theme means the
   section beneath is dark or is the blue footer field, and the open menu panel
   is brand blue itself. In both the mark reverses to white. */
.site-header--theme-light .site-header__mark,
.site-header--theme-light .site-header__mark path,
.is-menu-open .site-header__mark,
.is-menu-open .site-header__mark path,
.site-header:has(.burger[aria-expanded="true"]) .site-header__mark,
.site-header:has(.burger[aria-expanded="true"]) .site-header__mark path {
  fill: var(--paper);
}

/* -------------------------------------------------------------------------
   3. Menu toggle
   A word, not an icon. MENU and CLOSE are stacked in a single grid cell so the
   control is as wide as the longer of the two and its right edge never moves as
   they swap. The cell clips, so each word can slide vertically out of and into
   view rather than simply cross-fading in place.
   ---------------------------------------------------------------------- */

.burger {
  display: flex;
  align-items: center;
  /* Brand blue rather than the header's inherited colour, so the label matches
     the mark opposite it instead of turning near black over the white sections.
     The two reversal rules below are the same pair the mark carries, for the
     same reason: blue on blue would disappear. */
  color: var(--blue-brand);
  cursor: pointer;
}

.site-header--theme-light .burger,
.is-menu-open .burger,
.site-header:has(.burger[aria-expanded="true"]) .burger {
  color: var(--paper);
}

.burger__label {
  display: grid;
  justify-items: end;
  overflow: hidden;
  font-size: var(--step-0);
  font-weight: 400;
  letter-spacing: .1em;
  line-height: 1;
  text-transform: uppercase;
}

/* Both words occupy the same cell. GSAP owns their transform and opacity, so
   nothing here may transition either property. */
.burger__word {
  grid-area: 1 / 1;
  display: block;
  white-space: nowrap;
}

/* The resting state and the no-JS fallback. CLOSE is out of sight until the
   timeline brings it in, and is inert to pointer and hit testing meanwhile. */
.burger__word.is-close {
  opacity: 0;
  visibility: hidden;
}

/* -------------------------------------------------------------------------
   4. Menu panel
   display:none at rest. The GSAP timeline sets display:flex, then wipes the
   panel open with a clip-path. It sits below the header in stacking order so
   the burger stays clickable throughout.
   ---------------------------------------------------------------------- */

.menu {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 90;
  flex-direction: column;
  justify-content: space-between;
  gap: var(--space-2xl);
  /* The top padding only has to clear the fixed header. It carried an extra
     --space-m of slack, which was harmless until the links grew their leading
     and pushed the panel's contents past the fold on a short laptop screen. */
  padding: var(--space-3xl) var(--page-margin) var(--space-xl);
  background: var(--blue-brand);
  color: var(--paper);
  overflow-y: auto;
  overscroll-behavior: contain;
  /* The panel is the brand colour, so the inherited blue thumb would be
     invisible against it. It reverses, the way the mark and the focus ring do
     on this same field. */
  scrollbar-color: rgba(255, 255, 255, .5) rgba(255, 255, 255, .12);
}

.menu__nav {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-3xs);
}

.menu__link {
  display: block;
  font-size: var(--step-3-5);
  font-weight: 300;
  letter-spacing: -.02em;
  /* 1.02 set the four items almost touching, which suited a heavier weight than
     these carry. At 300 the letterforms are open enough that they need the
     leading to match, or the stack reads as a block rather than as four
     separate destinations.

     Note that leading alone did not move them apart. Reducing the type shrank
     each line box by as much as the extra leading added, leaving the pitch
     within a pixel of where it started, so this value is set against the new
     smaller size rather than carried over from the old one. */
  line-height: 1.28;
  /* Dimmed by default and lifted to white for the current page, exactly as the
     footer nav does it, so the same information is signalled the same way in
     both places. Weight carries it as well as colour, because on the blue field
     a dim white and a full white are closer than they look. */
  color: var(--on-blue-dim);
  /* Hover fades via colour, deliberately not via opacity. The open timeline
     staggers these links in by tweening opacity, and a CSS transition on that
     same property would retarget on every frame GSAP writes, damping the
     expo.out cascade into a soft blur. Colour gives the identical visual
     result and leaves opacity entirely to GSAP. */
  transition: color var(--dur-fast) var(--ease);
}

.menu__link.is-current {
  font-weight: 400;
  color: var(--paper);
}

.menu__link:hover {
  color: var(--paper);
}

.menu__meta {
  max-width: 26ch;
  font-size: var(--step--1);
  line-height: 1.6;
  color: var(--on-blue-dim);
}

.menu__meta a:hover {
  color: var(--paper);
}

/* -------------------------------------------------------------------------
   5. Hero colour
   The colour adapter in main.js reads each section's own computed colour and
   gives it to the header, so declaring the colour here is what drives the mark
   and the MENU label over the photograph.

   The top of this photograph is pale limestone, measured at a mean of #BCA272
   under the mark and #C5AE85 under the label. White reads at only 2.12:1 there
   and fails AA at any size, which is why this section previously carried a
   dark gradient to prop it up. Brand blue needs no such help, measuring 3.84:1
   under the mark and 4.4:1 under the label. The gradient is therefore gone and
   the photograph is shown untreated, as the wireframe draws it.
   ---------------------------------------------------------------------- */

.hero {
  color: var(--blue-brand);
}

/* -------------------------------------------------------------------------
   6. Work card
   .work-card__media is the overflow-hidden frame. The image scales inside it
   on hover and is the element reveal.js unmasks. The caption is a separate
   reveal target so it can trail the image.

   The card carries exactly one link and it wraps both the photograph and the
   title, so either can be clicked. A second link around the title would point
   at the same page and be announced twice by a screen reader, which is worse
   than useless.

   A stretched pseudo element over a title-only link was the other candidate and
   was rejected. reveal.js puts a transform on the caption while it animates, a
   transformed ancestor becomes the containing block for absolutely positioned
   descendants, and the stretched area would silently collapse to the caption
   for the duration. Wrapping is not clever, but nothing can desynchronise it.
   ---------------------------------------------------------------------- */

.work-card {
  display: flex;
  flex-direction: column;
}

.work-card__link {
  display: block;
  color: inherit;
}

.work-card__media {
  display: block;
  position: relative;
  overflow: hidden;
  aspect-ratio: 3 / 2;
  background: var(--blue-tint);
}

.work-card__img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transform: scale(1);
  will-change: transform;
}

/* The hover transition is gated behind .is-revealed, which reveal.js sets once
   the scroll animation for this card has finished. Before that point GSAP is
   tweening transform on this same element to lift the image through its mask,
   and an unscoped CSS transition would fight it and damp the rise. Afterwards
   GSAP has cleared its inline transform and the property is free. */
.work-card.is-revealed .work-card__img {
  transition: transform var(--dur-slow) var(--ease);
}

/* Keyed to the card rather than the link so that hovering anywhere on the card,
   including the title, lifts the photograph too. Both halves of the card
   respond as one object, which is the point of it being a single link. */
.work-card:hover .work-card__img,
.work-card:focus-within .work-card__img {
  transform: scale(1.045);
}

.work-card__caption {
  /* Shrink to fit, so the underline drawn below is the width of the title
     rather than the width of the column. */
  display: inline-block;
  position: relative;
  margin-top: var(--space-xs);
  /* A half step up. --step-0 read as small print beneath the photograph and
     --step-1 competed with it, so the scale carries a documented value between
     the two rather than this rule carrying a one-off clamp. */
  font-size: var(--step-0-5);
  line-height: 1.35;
  color: var(--ink);
  /* Safe to transition. reveal.js writes transform and opacity on this element
     but never colour, so this cannot fight the card reveal. */
  transition: color var(--dur-fast) var(--ease);
}

/* The title's underline is a pseudo element rather than a text-decoration, so it
   can be wiped rather than merely faded.

   It draws in from the left on enter and retracts to the right on leave. That
   is the whole trick: the scale is animated while transform-origin flips
   between the two edges, so the line always travels in the direction the
   pointer implies instead of collapsing back the way it came. Retracting to the
   same edge it grew from is what makes most underline hovers feel mechanical.

   It has to be the pseudo element and not the caption itself. reveal.js owns
   transform and opacity on .work-card__caption while the card animates in, and
   a CSS transition on either of those properties would retarget on every frame
   GSAP writes and damp the reveal. GSAP cannot address a pseudo element, so
   ::after is the one surface here that CSS owns outright. */
.work-card__caption::after {
  content: "";
  position: absolute;
  inset-inline: 0;
  inset-block-end: -.18em;
  /* 1px read as a hairline against this type size and 2px read as a rule.
     1.5px sits between them and resolves cleanly on a retina screen. */
  height: 1.5px;
  background: var(--blue-brand);
  transform: scaleX(0);
  transform-origin: right center;
  transition: transform var(--dur) var(--ease);
}

.work-card:hover .work-card__caption::after,
.work-card__link:focus-visible .work-card__caption::after {
  transform: scaleX(1);
  transform-origin: left center;
}

.work-card:hover .work-card__caption,
.work-card__link:focus-visible .work-card__caption {
  color: var(--blue-brand);
}

/* -------------------------------------------------------------------------
   7. Site footer
   ---------------------------------------------------------------------- */

.site-footer {
  background: var(--blue-footer);
  color: var(--paper);
}

.site-footer__nav {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-3xs);
}

.site-footer__link {
  font-size: clamp(1.5rem, 1.09rem + 1.74vw, 2.5rem);
  font-weight: 300;
  letter-spacing: -.01em;
  line-height: 1.25;
  color: var(--on-blue-dim);
  transition: color var(--dur-fast) var(--ease);
}

.site-footer__link.is-current {
  font-weight: 400;
  color: var(--paper);
}

.site-footer__link:hover,
.site-footer__link:focus-visible {
  color: var(--paper);
}

/* Pages that do not exist yet.
   The markup keeps these in place so the navigation still reads as the finished
   thing, but they carry no href, which is what actually makes them inert: not
   clickable, not in the tab order, not announced as links, and not something
   Barba will try to navigate to. Nothing here is doing the disabling, it only
   stops them from behaving as though they were still live.
   The intended destination is parked in data-href, so restoring one is a matter
   of moving that value back and dropping the class. */
.site-footer__link.is-unavailable:hover,
.site-footer__link.is-unavailable:focus-visible,
.site-footer__accessibility.is-unavailable:hover,
.site-footer__accessibility.is-unavailable:focus-visible,
.menu__link.is-unavailable:hover,
.menu__link.is-unavailable:focus-visible {
  color: inherit;
}

.site-footer__accessibility {
  font-size: clamp(1.125rem, .92rem + .87vw, 1.625rem);
  color: var(--on-blue-dim);
  transition: color var(--dur-fast) var(--ease);
}

.site-footer__accessibility:hover,
.site-footer__accessibility:focus-visible {
  color: var(--paper);
}

.site-footer__brand {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: var(--space-2xs);
  width: min(100%, 24rem);
}

.site-footer__mark {
  width: 86%;
  height: auto;
  fill: currentColor;
}

.site-footer__wordmark {
  font-size: clamp(.75rem, .6rem + .65vw, 1.125rem);
  letter-spacing: -.005em;
  line-height: 1.2;
  text-align: right;
  color: var(--paper);
}

.site-footer__address {
  font-size: var(--step-0);
  line-height: 1.65;
  text-align: right;
  color: var(--on-blue-dim);
}

.site-footer__address a {
  transition: color var(--dur-fast) var(--ease);
}

.site-footer__address a:hover,
.site-footer__address a:focus-visible {
  color: var(--paper);
}

/* -------------------------------------------------------------------------
   8. Narrow viewports
   ---------------------------------------------------------------------- */

@media (max-width: 767px) {
  .site-footer__brand {
    align-items: flex-start;
    width: min(100%, 17rem);
  }

  .site-footer__wordmark,
  .site-footer__address {
    text-align: left;
  }
}

/* -------------------------------------------------------------------------
   9. Project hero
   Full bleed, unlike the inset hero on the home page. The wireframe runs the
   project photograph edge to edge and only starts the white page frame at the
   intro beneath it.

   Structure is stated here rather than in layout.css because the project page
   was added after that file was closed, and keeping one project block together
   is easier to follow than splitting six selectors across two stylesheets.

   No transition on any property GSAP touches. The colour adapter in main.js
   tweens colour on [data-header] and reveal.js tweens transform, opacity and
   clip-path inside [data-reveal], so nothing below transitions those.
   ---------------------------------------------------------------------- */

.project-hero {
  position: relative;
  color: var(--paper);
}

.project-hero__img {
  width: 100%;
  height: clamp(18rem, 52vw, 88svh);
  object-fit: cover;
  object-position: center;
}

/* The top of the hero photograph is mid-tone cedar and the mark has to stay
   legible over it. Same short gradient the home hero uses, run to the full
   width here because this section is full bleed. */
.project-hero::after {
  content: "";
  position: absolute;
  inset-block-start: 0;
  inset-inline: 0;
  height: clamp(7rem, 14vh, 11rem);
  background: linear-gradient(
    to bottom,
    rgba(0, 0, 0, .52) 0%,
    rgba(0, 0, 0, .44) 28%,
    rgba(0, 0, 0, .16) 62%,
    rgba(0, 0, 0, 0) 100%
  );
  pointer-events: none;
}

/* -------------------------------------------------------------------------
   10. Project intro
   Portrait thumbnail in a narrow left column, title and body copy alongside.
   ---------------------------------------------------------------------- */

.project-intro {
  display: grid;
  grid-template-columns: minmax(0, 4fr) minmax(0, 8fr);
  column-gap: var(--gutter);
  row-gap: var(--space-l);
  align-items: start;
  padding-inline: var(--page-margin);
  padding-block: var(--space-2xl) var(--space-xl);
}

.project-intro__thumb {
  width: 100%;
  height: auto;
}

.project-intro__title {
  font-size: var(--step-3);
  font-weight: 300;
  letter-spacing: -.02em;
  line-height: 1.05;
}

.project-intro__text {
  margin-top: var(--space-m);
  max-width: 68ch;
  font-size: var(--step-0);
  line-height: 1.7;
}

/* -------------------------------------------------------------------------
   11. Project figure
   The image sequence alternates across the same two column grid the home page
   uses. --right sits in the second column, --left in the first.

   .project-figure__media is the overflow-hidden frame reveal.js unmasks. It is
   named in reveal.js alongside .work-card__media, so the class must not be
   renamed here without updating that module.
   ---------------------------------------------------------------------- */

.project-figure {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  column-gap: var(--gutter);
  padding-inline: var(--page-margin);
  padding-block: var(--space-l);
}

.project-figure--left .project-figure__media {
  grid-column: 1;
}

.project-figure--right .project-figure__media {
  grid-column: 2;
}

.project-figure__media {
  display: block;
  overflow: hidden;
  background: var(--blue-tint);
}

.project-figure__img {
  width: 100%;
  height: auto;
}

/* A left figure followed by a right one rides up into it, which is the stagger
   the wireframe draws between the sunset and construction photographs. The two
   values cancel out at narrow widths and only open into an overlap on wide
   screens, so the pair never collides on a phone. */
.project-figure--left + .project-figure--right {
  margin-block-start: calc(var(--space-2xl) * -1);
}

/* -------------------------------------------------------------------------
   12. Project metadata and awards
   Image in the left column, text in the right. The dl / dt / dd rows become
   custom fields on the Project post type in the WordPress conversion, so the
   colon after each term is drawn by CSS rather than typed into the markup.
   ---------------------------------------------------------------------- */

.project-meta,
.project-awards {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  column-gap: var(--gutter);
  row-gap: var(--space-l);
  /* Centred against the photograph rather than hung from its top edge. Both
     lists are shorter than the picture beside them, so aligning to the start
     left them sitting high with a long fall of empty space underneath, which
     read as the text having come adrift rather than as a pairing. */
  align-items: center;
  padding-inline: var(--page-margin);
}

.project-meta {
  padding-block: var(--space-l) var(--space-xl);
}

.project-awards {
  padding-block: var(--space-l) var(--space-3xl);
}

.project-meta__media {
  grid-column: 1;
}

.project-meta__img {
  width: 100%;
  height: auto;
}

.project-meta__list {
  grid-column: 2;
}

.project-awards__media {
  grid-column: 1;
  justify-self: end;
  width: min(100%, 22rem);
}

.project-awards__img {
  width: 100%;
  height: auto;
}

.project-awards__body {
  grid-column: 2;
}

/* Set at the size the footer nav uses, which is the size the wireframe draws
   both of these blocks at. */
/* Specification list.

   The wireframe sets these as running lines, label and value on one row divided
   by a colon, at the same size as the body copy. At that size the labels and the
   values compete and the block reads as a paragraph that happens to contain
   facts. Stacking the label above its value, and dropping the label to small
   caps in the brand colour, turns it into something scannable: the eye runs down
   one column of labels and stops at the one it wants. The hairline between rows
   does the work the colons were doing. */
.project-meta__row {
  display: flex;
  flex-direction: column;
  gap: var(--space-3xs);
  padding-block: var(--space-s);
}

/* Rules separate rows, so they belong between them and nowhere else. A line
   above the first row and below the last one is not dividing anything, it is
   drawing a box around a list that does not need one. */
.project-meta__row + .project-meta__row {
  border-block-start: 1px solid var(--blue-hairline);
}

.project-meta__label {
  font-size: var(--step--1);
  font-weight: 400;
  letter-spacing: .08em;
  line-height: 1.2;
  text-transform: uppercase;
  color: var(--blue-brand);
}

.project-meta__value {
  font-size: var(--step-0);
  font-weight: 300;
  letter-spacing: -.01em;
  line-height: 1.3;
  color: var(--ink);
}

/* Awards.

   Same reasoning. Seven awards inside one comma separated sentence is a wall,
   and the commas inside the award names themselves make it genuinely ambiguous
   where one ends and the next begins. One row each, with the year set apart in
   its own column, removes the ambiguity and lets the list be counted at a
   glance. */
.project-awards__title {
  font-size: var(--step--1);
  font-weight: 400;
  letter-spacing: .08em;
  line-height: 1.2;
  text-transform: uppercase;
  color: var(--blue-brand);
  margin-block-end: var(--space-xs);
}

.project-awards__list {
  list-style: none;
  margin: 0;
  padding: 0;
}

.project-awards__item {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-m);
  padding-block: var(--space-xs);
  /* The rule above the first item is kept. Unlike the specification list this
     one sits directly under the Awards heading, where it is separating the
     label from the list rather than boxing the list in. The trailing rule is
     gone for the same reason it went there, it divides nothing. */
  border-block-start: 1px solid var(--blue-hairline);
}

.project-awards__name {
  font-size: var(--step-0);
  font-weight: 300;
  line-height: 1.3;
  color: var(--ink);
}

/* Tabular figures keep the years in a true column rather than a ragged one. */
.project-awards__year {
  flex: none;
  font-size: var(--step--1);
  font-weight: 400;
  font-variant-numeric: tabular-nums;
  letter-spacing: .04em;
  color: var(--ink-muted);
}

/* -------------------------------------------------------------------------
   14. Project page on narrow viewports
   Every grid collapses to one column and the stagger is dropped.
   ---------------------------------------------------------------------- */

@media (max-width: 767px) {
  .project-intro,
  .project-figure,
  .project-meta,
  .project-awards {
    grid-template-columns: minmax(0, 1fr);
  }

  .project-intro__media {
    width: min(100%, 17rem);
  }

  .project-meta,
  .project-awards {
    align-items: start;
  }

  .project-figure--left .project-figure__media,
  .project-figure--right .project-figure__media,
  .project-meta__media,
  .project-meta__list,
  .project-awards__media,
  .project-awards__body {
    grid-column: 1;
    justify-self: start;
  }

  .project-figure--left + .project-figure--right {
    margin-block-start: 0;
  }

  .project-hero__img {
    height: clamp(16rem, 78vw, 30rem);
  }
}

/* -------------------------------------------------------------------------
   15. Footer colophon
   The copyright and credit line, set small and dim beneath a hairline rule,
   in the register the reference site uses for the same information.
   ---------------------------------------------------------------------- */

.site-footer__colophon {
  padding-block-start: var(--space-s);
  border-block-start: 1px solid rgba(255, 255, 255, .18);
  font-size: var(--step--1-5);
  line-height: 1.5;
  letter-spacing: .01em;
  color: var(--on-blue-dim);
}

.site-footer__colophon a {
  color: inherit;
  text-decoration: none;
  transition: color var(--dur-fast) var(--ease);
}

.site-footer__colophon a:hover {
  color: var(--paper);
}

/* The pipe between the two statements is decoration, not content, so it is
   hidden from assistive technology and given room to breathe visually. */
.site-footer__colophon .sep {
  margin-inline: .5em;
  opacity: .55;
}

/* -------------------------------------------------------------------------
   16. Page transition curtain
   Created by transitions.js on first navigation, not written into the markup.
   It sits above the header so the whole page is covered while the container is
   swapped, and it never takes pointer events, so a click during a wipe still
   lands on whatever is underneath.

   GSAP owns clip-path here. Do not add a transition for it.
   ---------------------------------------------------------------------- */

.page-curtain {
  position: fixed;
  inset: 0;
  z-index: 150;
  background: var(--blue-brand);
  clip-path: polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%);
  pointer-events: none;
  will-change: clip-path;
}

/* Nothing should wipe if the visitor has asked for less motion. transitions.js
   also declines to start Barba at all under this query, so this is belt and
   braces for any future code path that creates the element anyway. */
@media (prefers-reduced-motion: reduce) {
  .page-curtain {
    display: none;
  }
}

/* -------------------------------------------------------------------------
   17. Image lightbox
   Created by lightbox.js on the first project page, not written into the
   markup, the same arrangement the page curtain uses.

   The photograph sits directly on the dark ground. No panel, no frame, no
   border, no shadow, no counter, no arrows. One image at a time, opened and
   closed, and the negative space around it does the framing.

   Stacking. The header is 100 and the menu panel 90, so the overlay has to
   clear both or the burger would float over an enlarged photograph. The page
   transition curtain is 150 and deliberately stays above it, so a navigation
   still wipes over everything on screen. 140 sits in that gap. The skip link
   keeps 200 and remains reachable at the very top of the order.

   GSAP owns opacity, visibility and transform on .lightbox__backdrop,
   .lightbox__figure and .lightbox__close. Do not write a transition for any of
   those properties on those elements. A transition on a property GSAP is
   tweening retargets on every frame and damps the animation to a crawl. The
   close control transitions colour only, which GSAP never touches.

   display is deliberately NOT animated. lightbox.js swaps the hidden attribute
   instead, so the overlay is laid out the instant it opens rather than one
   frame later. Focus has to move into the dialog synchronously, and an element
   still carrying display:none cannot take focus at all.
   ---------------------------------------------------------------------- */

.lightbox {
  position: fixed;
  inset: 0;
  z-index: 140;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-xl) var(--page-margin);
}

.lightbox[hidden] {
  display: none;
}

/* A near-flat wash, almost black. The brand blue at strength would tint every
   photograph on the page, and the point of the overlay is that the image is the
   only thing in the frame carrying colour.

   .97 rather than .92, and the reason is worth knowing before anyone lowers it
   again. What sits behind the wash is not one flat field. It is a white page
   above and the blue footer below, and at .92 the boundary between them read as
   a visible horizontal seam across the ground with the footer's MCM mark
   ghosting through it. The wash has to be flat for the photograph to be the only
   thing in the frame, and it is only as flat as the page it is covering allows. */
.lightbox__backdrop {
  position: absolute;
  inset: 0;
  /* Opaque, not a wash. At any translucency the header's MCM mark and the seam
     between the white page and the blue footer ghost through, which reads as a
     rendering fault rather than as depth. A flat ground also serves the
     photograph better, since it is then the only thing in the frame. */
  background: #111111;
}

.lightbox__figure {
  position: relative;
  z-index: 1;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-s);
  max-width: 100%;
  max-height: 100%;
}

/* Capped well inside the viewport on both axes, and the aspect ratio is kept,
   so a wide hero and a tall portrait are each held clear of the edges without
   either being cropped or stretched.

   82svh is the upper bound the design asks for and it binds on a tall window.
   The second term of the min() is what binds on an ordinary laptop, and it is
   the important one. It reserves the padding above and below plus room for a
   caption that runs to three or four lines on a phone. Without that reserve the
   figure grows taller than the space it is centred in, centring an overflowing
   item clamps it to one edge rather than splitting the difference, and a tall
   portrait pushes its own caption off the bottom of the screen. */
.lightbox__img {
  display: block;
  width: auto;
  height: auto;
  max-width: min(90vw, 100%);
  max-height: min(82svh, calc(100svh - 15rem));
  object-fit: contain;
}

/* Small and quiet, aligned to the left edge of the photograph. It is read by
   everyone, so it says something about the millwork rather than repeating the
   alt text, which is written for screen readers alone.

   width:0 with min-width:100% is what keeps the caption from setting the width
   of the figure. Without it a long line of text would measure wider than a
   portrait photograph, the figure would stretch to fit the sentence, and the
   image would no longer sit above its own caption. */
.lightbox__caption {
  width: 0;
  min-width: 100%;
  color: var(--on-blue-dim);
  font-size: var(--step--1);
  line-height: 1.4;
  /* Centred under the photograph. The width pair above keeps the caption from
     setting the figure's width, so this centres against the image's own width
     rather than against a box the text itself decided on. */
  text-align: center;
  text-wrap: balance;
}

/* A word, not an icon, and set exactly as the header toggle is set. It sits at
   the header's own padding, so it lands where the burger's CLOSE would be and
   the two never appear to be different controls. */
.lightbox__close {
  position: absolute;
  z-index: 2;
  inset-block-start: var(--space-m);
  inset-inline-end: var(--page-margin);
  color: var(--on-blue-dim);
  font-size: var(--step-0);
  font-weight: 400;
  letter-spacing: .1em;
  line-height: 1;
  text-transform: uppercase;
  cursor: pointer;
  transition: color var(--dur-fast) var(--ease);
}

.lightbox__close:hover,
.lightbox__close:focus-visible {
  color: var(--paper);
}

/* A blue focus ring would disappear into the dark ground, so it reverses to
   white the way it already does on the footer and the menu panel. */
.lightbox :focus-visible {
  outline-color: var(--paper);
}

/* Every photograph on a project page is a control, so it takes the pointer that
   any other control would. Deliberately not zoom-in: a magnifying cursor is an
   operating system glyph dropped into a page that otherwise has no icons in it
   at all, and it announces the mechanism rather than the invitation. The lift
   below does that job instead. */
[data-lightbox-trigger] {
  cursor: pointer;
}

/* The same lift the home page cards use, so a photograph behaves the same way
   wherever it appears. Each image sits in a frame that clips, so it grows
   inside its own bounds rather than over its neighbours.

   The transition is gated behind .is-revealed on anything reveal.js animates,
   for the reason set out on the work card: GSAP is tweening transform on these
   same images to lift them through their masks, and an ungated transition would
   fight it. The hero and the intro thumbnail are not reveal targets, so they
   carry the transition outright. */
.project-hero,
.project-intro__media,
.project-meta__media,
.project-awards__media,
.project-figure__media {
  display: block;
  overflow: hidden;
}

.project-hero__img,
.project-intro__thumb {
  transform: scale(1);
  will-change: transform;
  transition: transform var(--dur-slow) var(--ease);
}

.project-meta__img,
.project-awards__img,
.project-figure__img {
  transform: scale(1);
  will-change: transform;
}

.is-revealed .project-meta__img,
.is-revealed .project-awards__img,
.is-revealed .project-figure__img {
  transition: transform var(--dur-slow) var(--ease);
}

.project-hero:hover .project-hero__img,
.project-intro__media:hover .project-intro__thumb,
.project-meta__media:hover .project-meta__img,
.project-awards__media:hover .project-awards__img,
.project-figure__media:hover .project-figure__img,
[data-lightbox-trigger]:focus-visible {
  transform: scale(1.045);
}

/* The overlay is fixed and the wash covers the page, but the document behind it
   must not scroll underneath. Lenis is stopped by the module as well, and this
   holds the page still on the paths where Lenis is not running at all. */
.is-lightbox-open,
.is-lightbox-open body {
  overflow: hidden;
}

/* -------------------------------------------------------------------------
   18. Scattered project index
   The wireframe sets the Projects page as a loose, staggered run rather than
   the strict grid the home page uses. Projects alternate sides, the right hand
   column drops below the left, and the widths vary so the eye travels down the
   page rather than scanning it in rows.

   It is still a two column grid underneath. The alternation comes from the
   normal auto flow, the stagger from a margin on the right hand column, and the
   variation from a width per item. Nothing is positioned absolutely and no
   height is hard coded, so a portrait sitting beside a landscape simply makes
   its own row taller rather than pushing anything out of place.
   ---------------------------------------------------------------------- */

.work-scatter {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  column-gap: var(--gutter);
  row-gap: var(--space-3xl);
  align-items: start;
  padding-inline: var(--page-margin);
  /* The top padding clears the fixed header. This page opens straight onto the
     photographs, with no hero and no title block above them to do it. */
  padding-block: var(--space-3xl) var(--space-3xl);
}

/* Every second project falls in the right hand column, and drops so the two
   never line up along a shared top edge. This is what breaks the grid. */
.work-scatter__item:nth-child(even) {
  margin-block-start: var(--space-3xl);
}

/* Widths are taken from the wireframe, where the portraits sit narrow and the
   landscapes run closer to the full column.

   Anything given a justify-self must also be given a width. Setting justify-self
   drops the item out of the grid's default stretch, so without a width it falls
   back to fit-content, and an image sized at 100% of a fit-content box collapses
   to almost nothing. */
.work-scatter__item--a,
.work-scatter__item--d,
.work-scatter__item--g {
  width: 68%;
}

.work-scatter__item--c,
.work-scatter__item--e {
  width: 92%;
}

/* Red Bull sits in from the right gutter rather than hard against it, which is
   the indent the wireframe draws. */
.work-scatter__item--d {
  justify-self: end;
  margin-inline-end: var(--space-2xl);
}

@media (max-width: 767px) {
  .work-scatter {
    grid-template-columns: minmax(0, 1fr);
    row-gap: var(--space-xl);
    padding-block: var(--space-2xl) var(--space-2xl);
  }

  /* One column, so the stagger and the width variation both come off. They
     describe a relationship between two columns that no longer exists, and left
     in place they would read as items randomly indented and undersized. */
  .work-scatter__item,
  .work-scatter__item:nth-child(even) {
    width: 100%;
    justify-self: start;
    margin-block-start: 0;
    margin-inline-end: 0;
  }
}

/* -------------------------------------------------------------------------
   19. Pointer ring
   A ring and a dot that follow the pointer, drawn white with a dark hairline
   either side of the stroke.

   This was mix-blend-mode: difference, which inverts the backdrop instead of
   carrying a colour. That is elegant at the extremes and fails in the middle:
   inverting a mid tone returns something close to the mid tone it started from,
   so over the brass wall on the home hero the ring went almost invisible. The
   photography this site is built on is mostly mid tone timber and stone, which
   is precisely the range the technique cannot serve.

   White with a dark hairline is less clever and always legible. The white reads
   against the photography and the blue panels, the hairline reads against the
   white pages, and neither depends on what is underneath.

   Nothing here transitions transform on the outer element. cursor.js writes
   that property every frame, and a transition on it would fight the easing and
   smear the ring. Scale lives on the inner rules, which JS never touches, so
   the two cannot collide.
   ---------------------------------------------------------------------- */

.cursor {
  position: fixed;
  inset-block-start: 0;
  inset-inline-start: 0;
  z-index: 300;
  width: 1.75rem;
  height: 1.75rem;
  /* Never a hit target. Without this the ring would sit between the pointer and
     every link on the page. */
  pointer-events: none;
  opacity: 0;
  will-change: transform;
}

/* The ring proper. It is a child rather than the positioned element itself so
   that JS owns the translation and CSS owns the centring and the scale, with
   neither overwriting the other.

   The two box-shadows are hairlines just outside and just inside the white
   stroke. On a pale photograph or a white page they are what you actually see;
   on anything darker they disappear and the white does the work. */
.cursor::after {
  content: "";
  position: absolute;
  inset: 0;
  border: 1.5px solid #FFFFFF;
  border-radius: 50%;
  box-shadow:
    0 0 0 1px rgba(17, 17, 17, .28),
    inset 0 0 0 1px rgba(17, 17, 17, .28);
  /* Centres the ring on the pointer. */
  transform: translate(-50%, -50%) scale(1);
  transition: transform var(--dur-fast) var(--ease);
}

/* Held back until the pointer first moves, so the ring does not sit in the top
   left corner of every fresh page load waiting to be found. */
.cursor.is-awake {
  opacity: 1;
  transition: opacity var(--dur-fast) var(--ease);
}

/* Over anything clickable the ring opens up. It stays an outline rather than
   filling: a solid white disc would vanish against a pale photograph, which is
   the same trap the blend mode fell into, and it covered far too much of the
   picture the visitor is trying to look at. */
.cursor.is-over::after {
  transform: translate(-50%, -50%) scale(1.35);
}

/* The dot sits exactly on the pointer while the ring trails it. It is what
   gives the pair its precision: a lagging ring alone never quite tells you
   where you are actually pointing, and it is only by having something exact to
   trail that the ring's lag reads as deliberate. */
.cursor-dot {
  position: fixed;
  inset-block-start: 0;
  inset-inline-start: 0;
  z-index: 301;
  width: .375rem;
  height: .375rem;
  pointer-events: none;
  opacity: 0;
  will-change: transform;
}

.cursor-dot::after {
  content: "";
  position: absolute;
  inset: 0;
  background: #FFFFFF;
  border-radius: 50%;
  box-shadow: 0 0 0 1px rgba(17, 17, 17, .32);
  transform: translate(-50%, -50%) scale(1);
  transition: transform var(--dur-fast) var(--ease);
}

.cursor-dot.is-awake {
  opacity: 1;
  transition: opacity var(--dur-fast) var(--ease);
}

/* The dot stays put now that the ring no longer fills. It has nothing to hide
   inside, and it is the only part that marks the exact point. */
.cursor-dot.is-over::after {
  transform: translate(-50%, -50%) scale(1);
}

/* The arrow is hidden only once the ring is running, and the class is added by
   cursor.js rather than sitting in the markup, so a page whose JavaScript never
   runs keeps its ordinary pointer. */
.has-custom-cursor,
.has-custom-cursor a,
.has-custom-cursor button,
.has-custom-cursor [role="button"],
.has-custom-cursor [data-lightbox-trigger] {
  cursor: none;
}

/* Touch has no pointer to replace, and a trailing ring is motion. cursor.js
   declines to start in both cases; this is the stylesheet agreeing. */
@media (pointer: coarse), (prefers-reduced-motion: reduce) {
  .cursor,
  .cursor-dot {
    display: none;
  }

  .has-custom-cursor,
  .has-custom-cursor a,
  .has-custom-cursor button,
  .has-custom-cursor [role="button"],
  .has-custom-cursor [data-lightbox-trigger] {
    cursor: auto;
  }
}
