/* ============================================================
   Section-level styling, in narrative order
   ============================================================ */

/* ---- shared -------------------------------------------------- */

/* Reserve space for artwork before it loads, so nothing reflows
   mid-scroll. Each ratio matches the generated plate's viewBox. */
#demos .demo-info .cell-image img,
/* Artwork in assets/images/ is generated at 3:4; these boxes match it. */
#process .image img { aspect-ratio: 3 / 4; }

/* A column that holds still while its sibling column scrolls. */
.pin-col {
  position: sticky;
  top: 0;
  height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

@media screen and (max-width: 47.99em) {
  .pin-col { position: static; height: auto; padding: 12vh 0; }
}

/* ==============================================================
   00 / 01 — Intro
   ============================================================== */

#intro .stage { justify-content: center; }
#intro [data-layer] { position: absolute; inset: 0; display: flex; align-items: center; }

#intro .logos { align-items: stretch; }
#intro .logos .grid-container { width: 100%; }
#intro .logos .grid-x { min-height: 100vh; padding: 5vh var(--pad-x); }

#intro .hero-grid {
  --hero-fs: clamp(64px, 15vw, 220px);
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
}
/* ---- the arrival ------------------------------------------------
   The one authored moment on the page. Three words rise into place one
   after the other, each from behind its own line, and then the chrome
   fades up behind them. Everything else on the site earns its motion
   from the scroll; this is the only thing that moves on its own.

   The lines are clipped rather than faded, so the type arrives already
   solid — a headline this size crossfading up from nothing reads as a
   page still loading.

   No fill mode, and no animation-delay anywhere: the hold is written
   INTO each keyframe set instead. That is deliberate and it matters
   more than the effect does. With no fill, an element's resting style
   is its ordinary visible one, so a browser that never runs the
   animation shows the headline and the navigation. Two earlier versions
   of this — one driven by a JS class, one using `backwards` fill — both
   left the entire hero and the whole nav invisible when the animation
   did not start. An entrance is not worth a blank page. */
#intro .hero-grid h1 .line {
  display: block;
  overflow: hidden;
}
#intro .hero-grid h1 .line > span { display: block; }

#intro .hero-grid h1 .line:nth-child(1) > span { animation: hero-rise-1 1100ms var(--ease-out); }
#intro .hero-grid h1 .line:nth-child(2) > span { animation: hero-rise-2 1190ms var(--ease-out); }
#intro .hero-grid h1 .line:nth-child(3) > span { animation: hero-rise-3 1280ms var(--ease-out); }

@keyframes hero-rise-1 {
  0%   { transform: translateY(100%); }
  100% { transform: none; }
}
@keyframes hero-rise-2 {                 /* 90ms of hold, then the same rise */
  0%, 7.6% { transform: translateY(100%); }
  100%     { transform: none; }
}
@keyframes hero-rise-3 {                 /* 180ms of hold */
  0%, 14%  { transform: translateY(100%); }
  100%     { transform: none; }
}

/* The chrome has no business competing with the headline for the first
   second, so it holds until the last word has landed and then fades. */
.site-nav, .scroll-cue, .progress-rail {
  animation: chrome-in 1320ms linear;
}
@keyframes chrome-in {
  0%, 47% { opacity: 0; }
  100%    { opacity: 1; }
}

/* Nobody who asked for less motion gets this, and neither does anyone
   arriving already scrolled — a hero animating somewhere above you,
   while the nav you are looking for waits, is worse than nothing. Both
   rules only ever REMOVE animation, so failing to apply them still
   leaves a working page. */
.no-intro #intro .hero-grid h1 .line > span,
.no-intro .site-nav, .no-intro .scroll-cue,
.no-intro .progress-rail { animation: none; }

@media (prefers-reduced-motion: reduce) {
  #intro .hero-grid h1 .line > span,
  .site-nav, .scroll-cue, .progress-rail { animation: none; }
}

#intro .hero-grid h1 {
  position: relative;
  z-index: 1;
  color: var(--black);
  text-align: center;
  font-size: var(--hero-fs);
  line-height: 0.86;
  letter-spacing: -0.04em;
  margin: 0;
}

@media screen and (max-width: 47.99em) {
  /* The wordmark is the whole screen on a phone, but 15vw falls under
     the 64px floor below ~427px, so it stopped scaling and sat small.
     20vw keeps the longest line ("Software", ~3.8x the font size) at
     roughly 93% of the available width, matching how it fills a laptop.
     The floor only applies under ~280px, narrower than any real device;
     the ceiling only bites approaching the 768px breakpoint. */
  #intro .hero-grid { --hero-fs: clamp(56px, 20vw, 150px); }
}

#intro .about p,
#intro .thesis p {
  font-size: clamp(22px, calc(22px + 16 * ((100vw - 375px) / 1065)), 38px);
  line-height: 1.3;
  text-align: center;
  color: var(--black);
  opacity: 0;
}

#intro .scroll-cue {
  position: absolute;
  left: 50%;
  bottom: 40px;
  transform: translateX(-50%);
  color: var(--black);
  z-index: 3;
}
#intro .scroll-cue svg { width: 14px; height: 22px; animation: nudge 2.4s ease-in-out infinite; }

@keyframes nudge {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(8px); }
}

/* ==============================================================
   02 — What We Build
   ============================================================== */

#what-we-build .stage { overflow: hidden; }
#what-we-build .words { text-align: center; }
#what-we-build .words p {
  font-size: var(--fs-display);
  font-weight: 500;
  letter-spacing: -0.03em;
  line-height: 0.9;
  margin: 0;
  text-transform: none;
}
#what-we-build .grid { padding-left: 8vw; }
#what-we-build .system { padding-right: 6vw; }

#what-we-build .black {
  position: absolute;
  inset: 0;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: transparent;
  opacity: 0;
  pointer-events: none;
}
/* The wipe reversed: the circle is a hole rather than a disc, so it
   closes in from the edges instead of growing out of the centre, and
   still finishes solid black for the section below.

   Drawn as a radial gradient rather than a scaled element. Scaling a
   disc to cover a 2000px screen asked the compositor for a texture
   past its 16384px limit and it dropped tiles — the flicker fixed
   earlier. A gradient repaints one full-bleed element instead, with
   no layer to overflow. main.js drives --r. */
#what-we-build .dot {
  position: absolute;
  inset: 0;
  /* The two stops must not mix units. `calc(<percentage> - 1px)` in a
     colour-stop position is rejected by Safari, and a rejected stop
     invalidates the whole background — the layer renders transparent and
     the wipe never goes black. Both stops are percentages now; the 0.4%
     gap is what keeps the edge from aliasing. */
  background: radial-gradient(circle at 50% 50%,
              transparent calc(var(--r, 150%) - 0.4%),
              var(--black) var(--r, 150%));
}

/* The left column is pinned, so it has to fit one screen. Size the
   plates against viewport height rather than column width, or the
   heading gets pushed above the fold on short viewports. */
#what-is-the-grid .content-left { max-height: 92vh; }

@media screen and (max-width: 47.99em) {
  /* The column doesn't pin at this size, so the scale-in entrance has
     nowhere to play out — it just overflows and gets clipped. */
  #what-is-the-grid .content-left h2,
  #what-is-the-grid .images {
    transform: none !important;
    opacity: 1 !important;
  }
}

/* The right column repeats the heading for small screens only,
   because the left column is what pins on desktop. */
#what-is-the-grid .content-right h2 { display: none; }

@media screen and (max-width: 47.99em) {
}

/* ==============================================================
   02 — Before the Grid
   ============================================================== */

/* Black, so the wipe that closes #what-we-build lands on the same
   colour it finished on and the two sections read as continuous. */
#worked-examples { background-color: var(--black); }
#worked-examples .content-left h2 { margin-bottom: 40px; }

/* Top-align the copy with the plate rather than centring it in the
   pinned column. Centred, the heading sat ~100px below the top of the
   image and the two columns stopped reading as one spread — the whole
   point of the layout is that the heading and the plate start together. */
@media print, screen and (min-width: 48em) {
  #worked-examples .pin-col {
    justify-content: flex-start;
    padding-top: 11vh;
  }
}

#worked-examples .texts { position: relative; min-height: 42vh; }
#worked-examples .text {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 500ms linear;
  pointer-events: none;
}
#worked-examples .text.is-showing { opacity: 1; pointer-events: auto; }
#worked-examples .caption-first { margin-top: 24px; }

/* The notes exist twice: the original cross-fades in the pinned left
   column here, and a clone (added by main.js) rides inside each stage
   for the single-column phone layout. Only one is ever displayed. */
#worked-examples .note-inline { display: none; }

#worked-examples .plate .stage {
  justify-content: center;
  overflow: visible;
}
#worked-examples .image-caption {
  width: 100%;
  transform-origin: 50% 50%;
}
/* Two of the three plates are dark studio photographs. On a black
   ground their corners merge into the page, so they get a hairline
   instead of the drop shadow, which does nothing here. */
/* 5:7 — the reference plate's proportion. At 3:4 the image sat short
   against a six-column text block and the two columns stopped
   reading as a pair. */
#worked-examples .image {
  width: 100%;
  aspect-ratio: 5 / 7;
  background-repeat: no-repeat;
  /* 105%, not 100%: the box is 5:7 and the artwork is 3:4, and
     background-size is width-based, so 100% stops 20px short of the
     bottom edge. The zoom tween starts from this same value. */
  background-size: 105%;
  background-position: 0% 0%;
  outline: 1px solid rgba(255, 255, 255, 0.16);
  outline-offset: -1px;
  /* These three arrive as background-images, so the reset in base.css
     doesn't reach them. */
  filter: grayscale(1);
}

@media screen and (max-width: 47.99em) {
  /* One column. main.js moves each plate's notes inside its own stage,
     so image and words pin, hold and exit as one unit — the desktop
     beat, stacked rather than split across two columns. */
  #worked-examples .grid-x {
    flex-direction: column;
    flex-wrap: nowrap;
  }
  #worked-examples .grid-margin-x { margin-inline: 0; }

  #worked-examples .cell,
  #worked-examples .pin-col,
  #worked-examples .content-left,
  #worked-examples .texts,
  #worked-examples .content-right {
    display: contents;
  }

  #worked-examples h2,
  #worked-examples .plate {
    flex: 0 0 auto;
    box-sizing: border-box;
    width: calc(100vw - var(--pad-x) * 2);
    max-width: 100%;
    min-width: 0;
    margin-inline: 0;
  }

  #worked-examples h2                 { order: 0; padding-top: 10vh; }
  #worked-examples #example-member-credit { order: 1; }
  #worked-examples #example-craft-market               { order: 2; }
  #worked-examples #example-dispatch-board          { order: 3; }

  /* The stage now carries a plate AND its notes, so the image is sized
     against viewport height rather than column width — otherwise a 3:4
     plate at full width leaves nothing for the words. */
  /* Grows past the viewport rather than squeezing the plate when a
     short screen cannot hold image and notes at once. */
  #worked-examples .plate .stage {
    justify-content: center;
    height: auto;
    min-height: 100svh;
    padding: 3svh 0;
    overflow: hidden;
  }
  /* Width-led: the plate spans the same column edges as the heading and
     the notes, so the section lines up against the layout grid. Height
     follows from the 3:4 ratio and is not capped — capping it made the
     plate narrower than the text sitting under it. */
  #worked-examples .image {
    width: 100%;
    max-width: 100%;
    max-height: none;
  }

  @media screen and (max-height: 640px) {
    /* Below this there is no arrangement that shows a full-width plate
       and its notes together, so give height back to the notes. */
    #worked-examples .image {
      width: auto;
      max-height: calc(100svh - 250px);
      margin-inline: auto;
    }
  }

  /* On a phone the live copy is the clone inside .image-caption, so it
     pins, zoom-holds and exits as one unit with the plate. The original
     is hidden — but only once main.js has confirmed every clone landed,
     so a script failure degrades to "notes in the wrong place" rather
     than "no notes at all". */
  #worked-examples.notes-cloned .texts > .text { display: none; }

  #worked-examples .texts { position: static; min-height: 0; }
  #worked-examples .note-inline {
    display: block;
    position: static;
    inset: auto;
    opacity: 1;
    pointer-events: auto;
    padding-top: 1.25rem;
    transition: none;
  }
  #worked-examples .text p {
    max-width: 100%;
    overflow-wrap: break-word;
  }
  #worked-examples .text > p:first-child {
    font-size: var(--fs-caption);
    line-height: 1.45;
  }
  #worked-examples .caption-first { margin-top: 14px; }
}

/* ==============================================================
   03 — How We Work panels
   ============================================================== */

#how-we-work .stage { overflow: hidden; background-color: var(--black); }
#how-we-work .white-reveal {
  position: absolute;
  inset: 0;
  background-color: var(--white);
  opacity: 0;
  pointer-events: none;
}
#how-we-work .parts {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  transform-origin: 40% 50%;
}
#how-we-work .top,
#how-we-work .bottom {
  flex: 1 1 50%;
  background-color: var(--white);
  display: flex;
  overflow: hidden;
}
#how-we-work .top { align-items: flex-end; }
#how-we-work .bottom { align-items: flex-start; }

/* ==============================================================
   03 — Services
   ============================================================== */

#services .stage { justify-content: center; overflow: hidden; }
#services h2 { color: var(--black); white-space: nowrap; }
#services p { color: rgba(0, 0, 0, 0.72); }

/* ==============================================================
   03 — Alternate names, with shutter blink
   ============================================================== */

#services-detail .stage {
  justify-content: center;
  overflow: hidden;
  position: sticky;
}
#services-detail .blink {
  position: absolute;
  left: 0;
  width: 100%;
  height: 50%;
  background-color: var(--white);
  z-index: 2;
}
#services-detail .blink.top { top: 0; }
#services-detail .blink.bottom { bottom: 0; }

#services-detail .names {
  position: relative;
  z-index: 1;
  padding-inline: var(--pad-x);
}
#services-detail .text {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  margin: 0;
  opacity: 0;
}
/* Display treatment: three lines stacked and centred on one axis,
   sized so the longest word carries about two-thirds of the viewport.
   The three services are the claim this section exists to make, so
   they get the same weight as a chapter card rather than sitting at
   body scale. */
#services-detail .text .grid-container { text-align: center; }
#services-detail .text span {
  display: block;
  /* main.js measures each phrase's longest word and sets --fit so the
     line fills the measure. A vw size cannot do this: the three phrases
     have different word lengths, so one coefficient leaves "Website
     Design" at half the width while "Engineering" is near the edge.
     The clamp is the no-JS fallback. */
  font-size: var(--fit, clamp(46px, 12.5vw, 250px));
  font-weight: 500;
  letter-spacing: -0.035em;
  line-height: 0.92;
  color: var(--black);
  text-wrap: nowrap;
}
/* The last word of each phrase drops to grey rather than changing
   hue — the whole tonal range the theme has to work with. */
#services-detail .text .tail { color: var(--grey); }

/* ==============================================================
   03 — Process
   ============================================================== */

#process { background-color: var(--black); padding: 14vh 0; }
#process .content-left h2 { margin-bottom: 8vh; }
/* Each stage gets close to a full screen of its own, the way the
   reference gives a year its own viewport. Centring inside that height
   rather than padding the top keeps a short stage and a long one
   optically level with the plate beside them. */
#process .text[data-image-step] {
  min-height: 88vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding-bottom: 0;
}
#process .text { padding-bottom: 22vh; }
#process .text h3 {
  /* The marker carries the stage. Capped so "01  Discover" stays on one
     line in a six-column measure — the reference's 1939 is four glyphs
     and can afford far more. */
  font-size: clamp(40px, calc(40px + 44 * ((100vw - 375px) / 1065)), 84px);
  line-height: 0.98;
  letter-spacing: -0.03em;
  color: var(--grey);
  margin-bottom: 28px;
}
#process .text-last { padding-bottom: 0; min-height: 0; }

/* Half the size it was. The reference keeps this plate small and lets
   the copy hold the column — at 30% of the viewport it competed with
   the stage marker instead of supporting it. */
#process .images {
  position: relative;
  aspect-ratio: 3 / 4;
  width: 100%;
  max-width: 300px;
  margin-left: auto;
}
#process .image {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 700ms linear;
}
#process .image.is-active { opacity: 1; }
#process .image p { margin: 0; }
#process .image img {
  width: 100%;
  object-fit: cover;
  outline: 1px solid rgba(255, 255, 255, 0.16);
  outline-offset: -1px;
}
#process .image .caption { margin-top: 14px; }

@media screen and (max-width: 47.99em) {
  /* No pinned plate at this width, so the stages do not need a screen
     each — that would just be scrolling past empty space. They do need
     separating though: the padding has to be set on [data-image-step]
     itself, because that selector outranks `.text` and was zeroing the
     gap here to 0px, running each stage straight into the next marker. */
  #process .text { padding-bottom: 3rem; }
  #process .text[data-image-step] {
    min-height: 0;
    display: block;
    padding-bottom: 11vh;
  }
  #process .text-last { padding-bottom: 0; }
  /* The marker is the only thing announcing a new stage once the plate
     is gone, so give it room above as well as below. */
  #process .text h3 { margin-bottom: 20px; }
  #process .images { display: none; }
}

/* ==============================================================
   03 — The Stack
   Layer tabs on the left; on the right the active set orbits as a
   ring. Marks stay upright (counter-spin), ink until hovered.
   ============================================================== */

#stack .stage { justify-content: center; overflow: hidden; }

#stack .content-left p {
  max-width: 28ch;
  color: var(--black);
}

#stack .choices {
  margin-top: 40px;
  padding-top: 28px;
  border-top: 1px solid var(--grey-rule);
}
#stack .choice {
  display: flex;
  align-items: baseline;
  gap: 0.55em;
  cursor: pointer;
  opacity: 0.25;
  transition: opacity 300ms linear;
  margin-bottom: 10px;
}
#stack .choice:last-child { margin-bottom: 0; }
#stack .choice.is-active { opacity: 1; }
#stack .choice:hover { opacity: 0.6; }
#stack .choice.is-active:hover { opacity: 1; }
#stack .choice-n {
  flex: 0 0 auto;
  font-size: var(--fs-caption);
  letter-spacing: 0.04em;
  color: var(--grey);
  font-weight: 400;
}

/* Panels share one cell; only the active orbit runs. */
#stack .texts {
  display: grid;
  width: 100%;
  min-height: min(56vw, 420px);
  place-items: center;
}
#stack .text {
  grid-area: 1 / 1;
  opacity: 0;
  transition: opacity 400ms linear;
  pointer-events: none;
  width: 100%;
  display: grid;
  place-items: center;
}
#stack .text.is-active {
  opacity: 1;
  pointer-events: auto;
  z-index: 1;
}

/* Orbit ring — no lattice, no cells. Twelve marks on one path. */
#stack .stack-icons {
  --count: 12;
  --radius: clamp(120px, 22vw, 178px);
  --mark: clamp(36px, 5vw, 52px);
  --orbit-dur: 40s;
  list-style: none;
  margin: 0 auto;
  padding: 0;
  position: relative;
  width: calc(var(--radius) * 2 + var(--mark));
  height: calc(var(--radius) * 2 + var(--mark));
  color: var(--black);
  animation: stack-orbit var(--orbit-dur) linear infinite;
}
/* Faint path so the circle reads without becoming a card. */
#stack .stack-icons::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: calc(var(--radius) * 2);
  height: calc(var(--radius) * 2);
  translate: -50% -50%;
  border: 1px solid var(--grey-rule);
  border-radius: 50%;
  pointer-events: none;
  opacity: 0.45;
}
#stack .stack-icons li {
  --i: 0;
  position: absolute;
  left: 50%;
  top: 50%;
  width: var(--mark);
  height: var(--mark);
  margin: 0;
  padding: 0;
  /* Seat on the ring; cancel the seat angle so the mark starts upright. */
  transform:
    translate(-50%, -50%)
    rotate(calc(360deg / var(--count) * var(--i)))
    translate(var(--radius))
    rotate(calc(360deg / var(--count) * var(--i) * -1));
  cursor: default;
}
#stack .stack-icons li:nth-child(1)  { --i: 0; }
#stack .stack-icons li:nth-child(2)  { --i: 1; }
#stack .stack-icons li:nth-child(3)  { --i: 2; }
#stack .stack-icons li:nth-child(4)  { --i: 3; }
#stack .stack-icons li:nth-child(5)  { --i: 4; }
#stack .stack-icons li:nth-child(6)  { --i: 5; }
#stack .stack-icons li:nth-child(7)  { --i: 6; }
#stack .stack-icons li:nth-child(8)  { --i: 7; }
#stack .stack-icons li:nth-child(9)  { --i: 8; }
#stack .stack-icons li:nth-child(10) { --i: 9; }
#stack .stack-icons li:nth-child(11) { --i: 10; }
#stack .stack-icons li:nth-child(12) { --i: 11; }
#stack .stack-icons .stack-mark {
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
  /* Counter-spin keeps the glyph readable while the ring turns. */
  animation: stack-orbit-rev var(--orbit-dur) linear infinite;
}
#stack .stack-icons img {
  width: 100%;
  height: 100%;
  display: block;
  filter: grayscale(1) brightness(0);
  transform: scale(1);
  transform-origin: center;
  transition:
    filter var(--dur-fast) var(--ease-out),
    transform var(--dur-fast) var(--ease-out);
}
#stack .stack-icons li:hover img,
#stack .stack-icons li:focus-within img {
  filter: none;
  transform: scale(1.2);
}
/* Hovering the ring freezes the system so a mark can be read. */
#stack .stack-icons:hover,
#stack .stack-icons:hover .stack-mark,
#stack .text:not(.is-active) .stack-icons,
#stack .text:not(.is-active) .stack-mark {
  animation-play-state: paused;
}

@keyframes stack-orbit {
  to { transform: rotate(360deg); }
}
@keyframes stack-orbit-rev {
  to { transform: rotate(-360deg); }
}

@media (prefers-reduced-motion: reduce) {
  #stack .stack-icons,
  #stack .stack-mark {
    animation: none !important;
  }
  #stack .stack-icons img {
    transition: none;
  }
  #stack .stack-icons li:hover img,
  #stack .stack-icons li:focus-within img {
    transform: none;
  }
}
.reduced-motion #stack .stack-icons,
.reduced-motion #stack .stack-mark {
  animation: none !important;
}

@media screen and (max-width: 47.99em) {
  #stack .content-left { padding-top: 8vh; }
  #stack .content-left p { max-width: none; }
  #stack .choices { margin-top: 28px; padding-top: 22px; margin-bottom: 36px; }
  #stack .content-right { padding-bottom: 8vh; }
  #stack .texts { min-height: min(78vw, 340px); }
  #stack .stack-icons {
    --radius: clamp(100px, 30vw, 140px);
    --mark: clamp(32px, 9vw, 44px);
  }
}

/* ==============================================================
   03 — Studio
   ============================================================== */

#studio .stage { overflow: hidden; }
#studio [data-layer] {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
}

@media screen and (max-width: 47.99em) {
  /* Both layers are inset:0 and vertically centred, so they land on the
     same spot. Desktop keeps them apart horizontally — .more sits in a
     medium-offset-4 cell — but there are no offsets at this width, so
     the heading printed straight over the copy (29px of overlap).
     The heading never fades, so it cannot simply wait its turn: give it
     the top of the stage and start the copy below it. */
  /* The heading and the copy are separate absolutely-positioned layers,
     so they cannot flow as one block — they get placed from a shared
     anchor instead. Heading pinned at 24vh; copy the heading's height
     plus a 48px gap below it. Centring the copy on its own left 131px
     between the two and a 270px void underneath; anchoring them keeps
     the gap fixed at any viewport height and the pair roughly centred. */
  #studio .intro { align-items: flex-start; padding-top: 24vh; }
  #studio .more  { align-items: flex-start; padding-top: calc(24vh + 89px); }
}
#studio .intro h2 { transform-origin: left center; }

/* The map is the last layer in the source, so without this it paints
   over the heading and the copy that come before it in the scene. It is
   a backdrop for them and a subject only later, so it sits underneath
   throughout. */
#studio .map   { align-items: center; z-index: 0; }
#studio .intro,
#studio .more  { z-index: 1; }

/* ---- the atlas -------------------------------------------------
   One panel now instead of two, so it takes the whole stage. The
   map, the rules and the roster all stack on the same spot and take
   turns; only the map is ever laid out, the other two are overlays. */
.atlas {
  position: relative;
  flex: 1;
  align-self: stretch;
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
}

/* Full bleed. The drawing letterboxes inside this box at world scale,
   but the sea and the surround are the same paper, so the bands do not
   read as bands — and once the map zooms, the content covers the frame
   edge to edge anyway. */
.atlas-frame { position: absolute; inset: 0; }
.atlas-svg { display: block; width: 100%; height: 100%; overflow: hidden; }

.atlas-world { fill: #c2c2c2; }

/* The only colour in the whole site, spent on one country. It carries
   the whole point of the section, so it gets to break the rule. */
.kh-blue { fill: #032ea1; }
.kh-red  { fill: #e00025; }
.kh-wat  { fill: #ffffff; }
/* At world scale Cambodia is seventeen pixels of flag; the border keeps
   it a shape rather than a smudge. Non-scaling so it stays hairline all
   the way through the zoom. */
.kh-edge {
  fill: none;
  stroke: var(--black);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
}

/* Everything that marks the spot turns about the spot. 756.1 341.5 is
   Phnom Penh in viewBox units; it is repeated rather than tokenised
   because a custom property here would have to be re-resolved by the
   scrub engine on every frame. */
.atlas-zoom,
.atlas-mark > * {
  transform-box: view-box;
  transform-origin: 756.1px 341.5px;
}

.atlas-axis {
  stroke: var(--black);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
  opacity: 0.42;
}
.atlas-halo {
  fill: none;
  stroke: var(--black);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
  opacity: 0;
}
.atlas-ring {
  fill: none;
  stroke: var(--black);
  stroke-width: 2;
  vector-effect: non-scaling-stroke;
  opacity: 0;
}
/* Black body, hollow centre: the pin has to read on grey land, on the
   flag's red and blue, and on the white of Angkor Wat underneath it,
   and only the solid silhouette manages all three. */
.atlas-pin { fill: var(--black); opacity: 0; }

/* The halo is the one thing here that moves on its own clock — a slow
   pulse under the pin once the map has settled. It is decorative, so
   it goes away entirely when the visitor has asked for less motion. */
@media (prefers-reduced-motion: no-preference) {
  .atlas-halo { animation: atlas-pulse 2.8s var(--ease-out) infinite; }
}
@keyframes atlas-pulse {
  0%   { transform: scale(0.55); opacity: 0.55; }
  70%  { transform: scale(1.35); opacity: 0; }
  100% { transform: scale(1.35); opacity: 0; }
}

/* Centred under the point and clear of Cambodia's southern border at
   full zoom. --px/--py are written by placeAtlasLabel(); the
   percentages are the no-JS fallback and are only right when the frame
   happens to match the viewBox ratio. */
.atlas-label {
  position: absolute;
  left: var(--px, 75.61%);
  top: var(--py, 61.47%);
  margin: 0;
  transform: translateX(-50%);
  text-align: center;
  white-space: nowrap;
  opacity: 0;
}
.atlas-city {
  display: block;
  font-size: clamp(22px, 3.2vw, 42px);
  font-weight: 500;
  letter-spacing: -0.03em;
  line-height: 1;
}
.atlas-geo {
  display: block;
  margin-top: 8px;
  font-size: var(--fs-caption);
  letter-spacing: 0.06em;
  color: var(--grey);
  font-variant-numeric: tabular-nums;
}

.atlas .rules,
.atlas .people {
  position: absolute;
  inset: 0;
  z-index: 3;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 10vh 0;
  opacity: 0;
}
.atlas .eyebrow {
  font-size: var(--fs-h6);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--grey);
  margin-bottom: 40px;
}

/* ---- how we work ------------------------------------------------
   Eight rules read as a numbered list rather than a wall of equal
   sentences: the figures give the eye somewhere to land and the rules
   between them do the work an indent would do in print. */
.atlas .creed {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  column-gap: clamp(28px, 5vw, 80px);
}
.atlas .creed li {
  display: flex;
  align-items: baseline;
  gap: 18px;
  padding: 18px 0;
  border-top: 1px solid var(--grey-rule);
}
.atlas .creed .n {
  flex: 0 0 auto;
  font-size: var(--fs-caption);
  letter-spacing: 0.08em;
  color: var(--grey);
  font-variant-numeric: tabular-nums;
}
.atlas .creed .t {
  font-size: clamp(18px, 1.9vw, 27px);
  font-weight: 500;
  letter-spacing: -0.02em;
  line-height: 1.14;
}

/* ---- the person -------------------------------------------------- */
.atlas .who {
  font-size: clamp(38px, 5.4vw, 76px);
  font-weight: 500;
  line-height: 0.98;
  letter-spacing: -0.035em;
  margin: 0;
}
.atlas .role {
  font-size: clamp(17px, 1.6vw, 22px);
  color: var(--grey);
  margin: 14px 0 0;
}
.atlas .say {
  font-size: var(--fs-body);
  line-height: 1.34;
  margin: 32px 0 0;
  max-width: 34em;
}
.atlas .profile {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 12px;
  margin-top: 36px;
  font-size: var(--fs-h6);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  text-decoration: none;
  border-bottom: 1px solid currentColor;
  padding-bottom: 6px;
  transition: opacity 150ms linear;
}
.atlas .profile svg { width: 22px; height: 10px; flex: 0 0 auto; }
.atlas .profile:hover { opacity: 0.6; }
/* The rule under the word wants to sit tight against it, so the touch
   target is grown behind the link instead of by padding it out to the
   44px minimum and dragging the underline down with it. */
.atlas .profile::after {
  content: '';
  position: absolute;
  inset: -12px -10px;
}

@media screen and (max-width: 47.99em) {
  /* The pin and the ring are sized in viewBox units, so they shrink
     with the map. Scaling the whole mark group about the point puts
     them back without touching the drawing underneath. */
  .atlas-mark {
    transform-box: view-box;
    transform-origin: 756.1px 341.5px;
    transform: scale(1.9);
  }
  /* Anchored to the point, the label would run off the right edge at
     this width — Phnom Penh sits at 76% across. It moves to the foot
     of the panel and lets the pin speak for itself. */
  .atlas-label {
    left: var(--pad-x);
    right: var(--pad-x);
    top: auto;
    bottom: 6vh;
    margin: 0;
    transform: none;
    text-align: left;
    white-space: normal;
  }
  /* Nothing legible survives sharing 375px with a map, so the text
     layers take the ground outright at this width. */
  .atlas .rules,
  .atlas .people { background-color: var(--white); }
  .atlas .eyebrow { margin-bottom: 28px; }
  .atlas .creed { grid-template-columns: minmax(0, 1fr); }
  .atlas .creed li { padding: 13px 0; }
  .atlas .creed .t { font-size: 18px; }
  .atlas .say { margin-top: 24px; }
  .atlas .profile { margin-top: 28px; }
}

/* ==============================================================
   03 — Selected work (visual index + detail pane)
   Title spans the grid. Index is one preview per row — larger
   frames, more air — so it reads as a grid, not a mosaic.
   ============================================================== */

#demos {
  background-color: var(--white);
  padding: clamp(10vh, 14vh, 18vh) 0 clamp(12vh, 16vh, 20vh);
}

.build-title {
  margin: 0 0 clamp(48px, 8vh, 96px);
  line-height: var(--lh-tight);
  letter-spacing: -0.03em;
  max-width: 18ch;
}

.work-index { display: block; }

#demos .thumbnails {
  display: flex;
  flex-direction: column;
  gap: clamp(28px, 3.5vw, 48px);
}

#demos .thumb {
  appearance: none;
  border: 0;
  background: none;
  padding: 0;
  margin: 0;
  cursor: pointer;
  width: 100%;
  display: block;
  text-align: left;
  color: var(--grey);
  transition: color 180ms linear, opacity 180ms linear;
}
#demos .thumb:hover { opacity: 0.72; color: var(--black); }
#demos .thumb:focus-visible {
  outline: 2px solid var(--black);
  outline-offset: 6px;
}
#demos .thumb.is-active { color: var(--black); opacity: 1; }

#demos .thumb img {
  width: 100%;
  display: block;
  aspect-ratio: 4 / 3;
  object-fit: cover;
}

#demos .thumb .demo-frame--thumb {
  aspect-ratio: 4 / 3;
}

/* Selection is the one thing a border is still for. */
#demos .thumb.is-active .demo-frame--thumb {
  outline: 1px solid var(--black);
  outline-offset: -1px;
}

#demos .content-right { position: relative; }
#demos [data-demo-detail] { display: grid; }
#demos .text {
  grid-area: 1 / 1;
  align-self: start;
  opacity: 0;
  transition: opacity 350ms linear;
  pointer-events: none;
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
}
#demos .text--is-showing { opacity: 1; pointer-events: auto; }

@media screen and (max-width: 47.99em) {
  .build-title { margin-bottom: 40px; max-width: none; }
  /* No index and no selection on a phone. Choosing a thumbnail to reveal
     a panel is a pointer interaction that does not survive the width —
     and with the demos rendering at their own size there is nothing left
     to reveal. All eight stack, all eight are live. */
  #demos .work-index { display: none; }
  #demos .pin-col { padding: 0; }
  #demos .content-right { min-height: 0; position: relative; }
  #demos [data-demo-detail] { display: block; }
  #demos .text {
    position: relative;
    inset: auto;
    opacity: 1;
    pointer-events: auto;
    margin-bottom: 64px;
  }
  #demos .text:last-child { margin-bottom: 0; }
  /* The pinned column's height cap is a desktop concern; here each demo
     is as tall as it is. */
}

/* ---- Contact -------------------------------------------------
   The reference stacks underlines rather than boxes: the label carries
   the field, the rule carries the input. Translated into this site's
   black and white, the accent the reference spends on a gradient is
   spent on inversion instead — the one white object on a black page is
   the thing to press. */
#contact { padding: 15vh 0 13vh; }
.contact-title {
  font-size: clamp(40px, calc(40px + 52 * ((100vw - 375px) / 1065)), 92px);
  line-height: 0.97;
  letter-spacing: -0.035em;
  color: var(--white);
  margin: 0 0 clamp(48px, 7vw, 92px);
}

.contact-form .field { margin-bottom: clamp(30px, 3.6vw, 46px); }
.contact-form label {
  display: block;
  font-size: clamp(17px, 1.5vw, 21px);
  font-weight: 500;
  letter-spacing: -0.01em;
  color: var(--white);
  margin-bottom: 14px;
}
.contact-form input,
.contact-form textarea {
  display: block;
  width: 100%;
  appearance: none;
  background: none;
  border: 0;
  border-bottom: 1px solid rgba(255, 255, 255, 0.34);
  border-radius: 0;
  padding: 0 0 13px;
  font: inherit;
  font-size: var(--fs-body);
  line-height: 1.4;
  color: var(--white);
  transition: border-color 160ms linear;
}
.contact-form textarea { resize: vertical; min-height: 4.2em; }
.contact-form input:hover,
.contact-form textarea:hover { border-bottom-color: rgba(255, 255, 255, 0.6); }
.contact-form input:focus,
.contact-form textarea:focus { outline: none; border-bottom-color: var(--white); }
.contact-form input:focus-visible,
.contact-form textarea:focus-visible { outline: 2px solid var(--white); outline-offset: 5px; }
/* Chrome paints autofilled fields with its own yellow, which on black is
   the one thing louder than the button. */
.contact-form input:-webkit-autofill {
  -webkit-text-fill-color: var(--white);
  -webkit-box-shadow: 0 0 0 100px var(--black) inset;
}

.contact-form .field-error {
  color: var(--white);
  font-size: var(--fs-caption);
  margin: -14px 0 24px;
  padding-left: 14px;
  border-left: 1px solid var(--white);
}

.contact-form .send {
  appearance: none;
  border: 0;
  cursor: pointer;
  background-color: var(--white);
  color: var(--black);
  font: inherit;
  font-weight: 500;
  font-size: var(--fs-h6);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  padding: 17px 40px;
  min-height: 52px;
  border-radius: 999px;
  transition: opacity 160ms linear;
}
.contact-form .send:hover { opacity: 0.76; }
.contact-form .send:focus-visible { outline: 2px solid var(--white); outline-offset: 4px; }

@media screen and (max-width: 47.99em) {
  #contact { padding: 11vh 0 10vh; }
  .contact-form .send { width: 100%; }
}

/* ---- Footer --------------------------------------------------
   Four bands separated by hairlines: identity and links, then the
   ways to reach us, then the disclosure, then the base line. Each
   band sits on the same twelve columns as the rest of the site, so
   the footer lines up with the page above it rather than reading as
   a slab bolted to the bottom. */
#credits {
  background-color: var(--black);
  padding: 14vh 0 6vh;
  border-top: 1px solid rgba(255, 255, 255, 0.16);
}
#credits p { color: rgba(255, 255, 255, 0.68); font-size: var(--fs-caption); line-height: 1.6; }
#credits a { color: rgba(255, 255, 255, 0.68); text-decoration: none; transition: color 150ms linear; }
#credits a:hover { color: var(--white); }
#credits a:focus-visible { outline: 2px solid var(--white); outline-offset: 3px; }

.foot-mark {
  font-size: clamp(24px, 2.4vw, 34px) !important;
  font-weight: 500;
  letter-spacing: 0.01em;
  color: var(--white) !important;
  margin: 0 0 22px;
}
.foot-blurb { max-width: 42ch; margin: 0; }

.foot-head {
  font-size: var(--fs-caption);
  font-weight: 500;
  letter-spacing: 0.02em;
  color: var(--white);
  margin: 0 0 20px;
}
/* Licence credit. Quiet — it is an obligation, not a message — but real
   text a visitor can read, which is what the Freepik terms ask for. */
.foot-credit { margin-top: clamp(28px, 4vh, 44px); }
.foot-credit p {
  max-width: 78ch;
  font-size: var(--fs-caption);
  line-height: 1.6;
  color: rgba(255, 255, 255, 0.46);
}
.foot-credit a { color: rgba(255, 255, 255, 0.62); text-decoration: underline; }
.foot-credit a:hover { color: var(--white); }

.foot-list { list-style: none; margin: 0; padding: 0; }
.foot-list li { margin-bottom: 12px; }
.foot-list a { font-size: var(--fs-caption); line-height: 1.5; }
/* Entries that name something rather than link to it. Matched to the
   links' size and tone so the columns read as one list, but with no
   hover and no pointer, because there is nowhere to go. */
.foot-list--static li {
  font-size: var(--fs-caption);
  line-height: 1.5;
  color: rgba(255, 255, 255, 0.68);
}

.foot-reach {
  margin-top: clamp(56px, 7vw, 96px);
  padding-top: clamp(28px, 3vw, 40px);
  border-top: 1px solid rgba(255, 255, 255, 0.16);
}
.foot-contact { display: flex; flex-wrap: wrap; align-items: baseline; gap: 10px 28px; margin: 0; }
.foot-contact-label { color: rgba(255, 255, 255, 0.45); }
.foot-contact a { color: var(--white) !important; font-size: var(--fs-body); letter-spacing: -0.01em; }

.foot-social { list-style: none; display: flex; justify-content: flex-end; gap: 12px; margin: 0; padding: 0; }
.foot-social a {
  display: grid;
  place-items: center;
  width: 44px;
  height: 44px;
  border-radius: 50%;
  border: 1px solid rgba(255, 255, 255, 0.32);
  transition: border-color 150ms linear, background-color 150ms linear;
}
.foot-social svg { width: 18px; height: 18px; display: block; }
.foot-social a:hover { border-color: var(--white); background-color: rgba(255, 255, 255, 0.08); }

.foot-base {
  margin-top: clamp(40px, 5vw, 64px);
  padding-top: clamp(24px, 2.6vw, 34px);
  border-top: 1px solid rgba(255, 255, 255, 0.16);
}
.foot-base p { margin: 0; color: rgba(255, 255, 255, 0.45); }
.foot-rights { text-align: right; }

@media screen and (max-width: 47.99em) {
  #credits { padding: 10vh 0 6vh; }
  .foot-top .cell + .cell { margin-top: 44px; }
  .foot-social { justify-content: flex-start; margin-top: 28px; }
  .foot-base .cell + .cell { margin-top: 10px; }
  .foot-rights { text-align: left; }
}

/* ==============================================================
   Reduced motion — flatten the narrative into a plain document
   ============================================================== */

.reduced-motion .stage {
  position: static;
  height: auto;
  min-height: 100vh;
  overflow: visible;
}
.reduced-motion .scene { height: auto !important; }
.reduced-motion .pin-col { position: static; height: auto; padding: 10vh 0; }
.reduced-motion [data-tw],
.reduced-motion [data-stagger] > * {
  opacity: 1 !important;
  transform: none !important;
}
.reduced-motion #intro [data-layer],
.reduced-motion #studio [data-layer] {
  position: static;
  inset: auto;
}
.reduced-motion #intro .about p,
.reduced-motion #intro .thesis p { opacity: 1; }
.reduced-motion #services-detail .text,
.reduced-motion #worked-examples .text,
.reduced-motion #stack .text,
.reduced-motion #demos .text,
.reduced-motion #process .image {
  position: static;
  opacity: 1;
  pointer-events: auto;
}
.reduced-motion #worked-examples .plate .stage {
  min-height: 0;
  padding: 4vh 0 10vh;
}
.reduced-motion #services-detail .blink { display: none; }
.reduced-motion #what-we-build .black { display: none; }
.reduced-motion #how-we-work .white-reveal { display: none; }
.reduced-motion #how-we-work .parts { position: static; }
.reduced-motion .scroll-cue { display: none; }
/* The atlas without the scroll. `[data-tw] { transform: none }` above
   would leave the map at 1:1, where Cambodia is four pixels wide, so
   the zoom is re-applied as a fixed value and the three stacked
   layers are allowed to flow one after another instead. */
.reduced-motion .atlas {
  display: block;
  padding: 6vh var(--pad-x);
  overflow: visible;
}
.reduced-motion .atlas-zoom { transform: scale(6) !important; }
.reduced-motion .atlas-label {
  position: static;
  transform: none;
  padding-left: 0;
  margin-top: 20px;
  white-space: normal;
}
.reduced-motion .atlas .rules,
.reduced-motion .atlas .people {
  position: static;
  opacity: 1;
  padding: 0;
  margin-top: 8vh;
  background: none;
}


/* ---- Selected Work: live demo frames ------------------------
   Each frame renders its demo at a fixed logical width and scales
   it down. Scale factor --s is set in main.js. */
.demo-frame {
  display: block;
  position: relative;
  width: 100%;
  aspect-ratio: 4 / 3;
  overflow: hidden;
  background: var(--white);
}
.demo-frame iframe {
  position: absolute;
  top: 0;
  left: 0;
  width: 1180px;
  height: 885px;          /* replaced per demo by fitFrames() */
  border: 0;
  transform-origin: 0 0;
  transform: translate(var(--ox, 0px), var(--oy, 0px)) scale(var(--s, 0.2));
}

.demo-frame--thumb { pointer-events: none; }
.demo-frame--thumb iframe { pointer-events: none; }

.demo-frame--live {
  aspect-ratio: 16 / 10;   /* replaced per demo by fitFrames() */
  width: 100%;
}
@media print, screen and (min-width: 48em) {
  /* The pinned column is 100vh, so a taller demo would push straight out
     of it. On a phone that column is static and the cap is meaningless —
     scoping it here rather than overriding it later, because the override
     lost the cascade to this rule and squashed every demo to 37%. */
  .demo-frame--live { max-height: calc(100vh - 190px); }
}

#demos .demo-info > img {
  width: 100%;
  display: block;
}


/* The caption stack is a hierarchy, not three equal lines: the bold
   line names the build, the prose note explains it, the stack list
   closes it out. Matches the reference's attribution → note order. */
#worked-examples .caption-first { margin-top: 26px; margin-bottom: 10px; }
#worked-examples .caption-first strong { font-weight: 600; }
#worked-examples .text .caption + .caption { margin-top: 8px; }
#worked-examples .text .caption:last-child { color: var(--grey); }


/* The phrase is the whole screen here, so it runs closer to the edge
   than body copy does — the section's own gutter, not the page's. */
#services-detail .text .grid-container { padding-inline: 20px; }
@media print, screen and (min-width: 48em) {
  #services-detail .text .grid-container { padding-inline: var(--pad-x); }
}


/* ---- What do we do? -----------------------------------------
   The question is the whole viewport here, the way the reference
   sets it: one line filling the measure, with the answer dropped
   well below rather than tucked under the baseline. */
#services h2 {
  font-size: var(--fit, clamp(38px, 11vw, 155px));
  line-height: 0.94;
  letter-spacing: -0.035em;
  white-space: nowrap;
  margin-bottom: 0;
}
#services .cell + .cell { margin-top: 16vh; }

@media screen and (max-width: 47.99em) {
  /* Same narrow gutter the service phrases get: a question set at
     display size is the screen, so it runs closer to the edge than
     body copy does. The reference lets it overflow and clip; filling
     the measure instead keeps the question readable. */
  #services .grid-container { padding-inline: 20px; }
  #services .cell + .cell { margin-top: 10vh; }
}

/* ==============================================================
   iOS Safari — stages that must cover, not just fit
   ==============================================================
   .stage is sized in svh, the viewport with Safari's bars showing.
   The moment they hide, the visible area grows to lvh and a stage
   sized to svh is short by the height of those bars. For most
   sections that is invisible. For these three it is not: each hands
   off to the next section through a full-bleed layer inside the
   stage — the wipe, the white reveal, the shutters — and the stage's
   overflow:hidden means that layer cannot reach past it. The result
   on Safari is a band of the section's own background along the
   bottom, and a handoff that looks like it reverted.

   lvh is the bars-hidden height, so it covers in both states. The
   cost is that centred content sits a little low while the bars are
   up; a seam is worse than a slightly low line of type.
   ============================================================== */
@supports (height: 100lvh) {
  #what-we-build .stage,
  #how-we-work .stage,
  #services-detail .stage { height: 100lvh; }
}
