/* ===================================================================
   Horizon Symmetry — hero
   The reveal is a Unicorn Studio scene (project rKv14eUBrORETKYVXzBe).
   Everything here sits on top of it and must never tint it: no scrims,
   no overlays, no opacity on the artwork.
   =================================================================== */

@font-face {
  font-family: "Space Grotesk";
  src: url("../assets/fonts/SpaceGrotesk-Variable.woff2") format("woff2");
  font-weight: 300 700;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Satoshi";
  src: url("../assets/fonts/Satoshi-Regular.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "Satoshi";
  src: url("../assets/fonts/Satoshi-Medium.woff2") format("woff2");
  font-weight: 500; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "Satoshi";
  src: url("../assets/fonts/Satoshi-Bold.woff2") format("woff2");
  font-weight: 700; font-style: normal; font-display: swap;
}
/* Headings only. It has one weight and a bitmap skeleton, so it is never
   asked to set a paragraph and never asked to fake a bold. */
@font-face {
  font-family: "PP Mondwest";
  src: url("../assets/fonts/PPMondwest-Regular.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: swap;
}

:root {
  --void:       #000000;
  --ink:        #FFFFFF;
  --ink-dim:    #B9B2C6;
  --ink-faint:  #6E6879;
  --rule:       rgba(255, 255, 255, 0.15);
  --violet:     #9B3BE0;
  --violet-hot: #E6C4FA;

  --display: "Space Grotesk", "Segoe UI", system-ui, sans-serif;
  --pixel:   "PP Mondwest", "Space Grotesk", system-ui, sans-serif;
  --body:    "Satoshi", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;

  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --ease:     cubic-bezier(0.4, 0, 0.2, 1);

  --gutter: clamp(1.5rem, 4.4vw, 4rem);
}

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

/* The page holds still: it does not drift sideways, and a double tap on a
   heading does not zoom it.

   Pinch is deliberately left alone. Capping the scale is what strands a
   phone at whatever zoom it happened to reach, with no gesture left to
   undo it, and no layout is worth that. `manipulation` takes away the
   double tap, which is the accident; the pinch stays as the way back. */
html {
  -webkit-text-size-adjust: 100%;
  overflow-x: clip;
  overscroll-behavior-x: none;
  /* pan-x pan-y is every gesture except the pinch, refused declaratively.
     manipulation still allowed the pinch, which is why lock.js was holding
     a non-passive touchmove to refuse it by hand, and that listener was
     what took iOS off its threaded scroller. */
  touch-action: pan-x pan-y;
}

body {
  margin: 0;
  background: var(--void);
  color: var(--ink);
  font-family: var(--body);
  font-weight: 400;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  /* `clip`, not `hidden` — hidden makes body a scroll container and
     breaks position:sticky for the work stack */
  overflow-x: clip;
  overscroll-behavior-x: none;
  -webkit-user-select: none;
          user-select: none;
  -webkit-touch-callout: none;
  -webkit-tap-highlight-color: transparent;
}

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 100;
  padding: 0.75rem 1.25rem;
  background: var(--ink);
  color: var(--void);
  font-size: 0.875rem;
  border-radius: 0 0 0.5rem 0;
}
.skip-link:focus { left: 0; }

/* ---------- masthead ---------------------------------------------- */

/* absolute, not fixed: the logo belongs to the hero and scrolls away with
   it, so no project row ever has to make room for it */
.masthead {
  position: absolute;
  inset: 0 0 auto 0;
  z-index: 10;
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: clamp(1.4rem, 2.9vw, 2.25rem) var(--gutter);
  pointer-events: none;
}

.brand {
  display: inline-block;
  pointer-events: auto;
  border-radius: 0.35rem;
  opacity: 0;
  transition: opacity 1.1s var(--ease-out) 0.15s;
}
.is-ready .brand { opacity: 1; }

.brand__mark {
  display: block;
  width: auto;
  height: clamp(2.5rem, 3.5vw, 3.25rem);
  transition: transform 0.5s var(--ease-out), filter 0.5s var(--ease);
}
.brand:hover .brand__mark {
  transform: scale(1.04);
  filter: drop-shadow(0 0 30px rgba(230, 196, 250, 0.55));
}

/* ---------- burger and menu ----------------------------------------- */

/* Sits opposite the logo and, like it, belongs to the hero: both scroll
   away rather than following the page, so no pinned section has to make
   room for them. */
.burger {
  position: relative;
  z-index: 1;                  /* holds the shade behind it at -1 */
  pointer-events: auto;
  display: inline-flex;
  align-items: center;
  gap: 0.7rem;
  padding: 0.5rem 0.2rem 0.5rem 0.6rem;
  background: none;
  border: 0;
  /* full white, not the dim ink: it sits on the busiest part of the page */
  color: var(--ink);
  /* the pixel face, as on the headings: it sets small here, so it is given
     back the size the grotesque did not need */
  font-family: var(--pixel);
  font-weight: 400;
  font-size: clamp(0.95rem, 1.15vw, 1.12rem);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  cursor: pointer;
  opacity: 0;
  transition: opacity 1.1s var(--ease-out) 0.25s, color 0.3s var(--ease);
}
.is-ready .burger { opacity: 1; }
.burger:hover { color: var(--violet-hot); }

/* White type on a white starfield reads as nothing, and the hero is a
   third-party canvas we cannot reach into. So the button carries its own
   ground: an ellipse of shade sized to itself, dense under the label and
   gone by its edge, so it never registers as a plate on the artwork. */
.burger::before {
  content: "";
  position: absolute;
  inset: -0.85rem -1.4rem;
  z-index: -1;
  border-radius: 50%;
  background: radial-gradient(
    ellipse at center,
    rgba(0, 0, 0, 0.88) 0%,
    rgba(0, 0, 0, 0.76) 42%,
    rgba(0, 0, 0, 0.36) 68%,
    rgba(0, 0, 0, 0) 84%);
  pointer-events: none;
}

/* over the menu's own dark panel it has nothing to do */
.burger[aria-expanded="true"]::before { opacity: 0; transition: opacity 0.3s var(--ease); }
.burger:focus-visible { outline: 2px solid var(--violet-hot); outline-offset: 4px; }

.burger__box {
  position: relative;
  width: 1.5rem;
  height: 0.62rem;
}
.burger__box span {
  position: absolute;
  left: 0;
  width: 100%;
  height: 1px;
  background: currentColor;
  transition: transform 0.45s var(--ease-out), opacity 0.25s var(--ease);
}
.burger__box span:first-child { top: 0; }
.burger__box span:last-child  { bottom: 0; }

/* the two rules cross into an X once the panel is open */
.burger[aria-expanded="true"] .burger__box span:first-child {
  transform: translateY(0.3rem) rotate(45deg);
}
.burger[aria-expanded="true"] .burger__box span:last-child {
  transform: translateY(-0.31rem) rotate(-45deg);
}

.menu {
  position: fixed;
  inset: 0;
  z-index: 40;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: clamp(2rem, 6vh, 4rem);
  padding: clamp(4.5rem, 12vh, 8rem) var(--gutter) clamp(2rem, 6vh, 3.5rem);
  background: rgba(0, 0, 0, 0.94);
  backdrop-filter: blur(18px);
  -webkit-backdrop-filter: blur(18px);
  opacity: 0;
  transition: opacity 0.45s var(--ease);
}
.menu[hidden] { display: none; }
.menu.is-open { opacity: 1; }

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

.menu__list a {
  display: inline-block;
  padding: clamp(0.15rem, 0.8vh, 0.4rem) 0;
  color: var(--ink);
  /* set like every other heading on the site. Mondwest has one weight and
     needs no negative tracking: it is already tight on its own grid, and
     the taller ascenders want the extra leading. */
  font-family: var(--pixel);
  font-weight: 400;
  font-size: clamp(2.1rem, 7.2vw, 4.7rem);
  line-height: 1.16;
  letter-spacing: 0;
  text-decoration: none;
}

/* each line rises into place, one after the next */
.menu__list a span {
  display: inline-block;
  transform: translateY(0.5em);
  opacity: 0;
  transition: transform 0.7s var(--ease-out), opacity 0.7s var(--ease-out);
}
.menu.is-open .menu__list a span { transform: none; opacity: 1; }
.menu__list li:nth-child(1) a span { transition-delay: 0.06s; }
.menu__list li:nth-child(2) a span { transition-delay: 0.12s; }
.menu__list li:nth-child(3) a span { transition-delay: 0.18s; }
.menu__list li:nth-child(4) a span { transition-delay: 0.24s; }

.menu__list a:hover      { color: var(--violet-hot); }
.menu__list a:focus-visible { outline: 2px solid var(--violet-hot); outline-offset: 6px; }

.menu__foot {
  display: flex;
  flex-wrap: wrap;
  gap: clamp(1rem, 3vw, 2.5rem);
  margin-top: auto;
  padding-top: clamp(1.25rem, 3vh, 2rem);
  border-top: 1px solid var(--rule);
  color: var(--ink-faint);
  font-size: clamp(0.85rem, 1vw, 0.95rem);
}
.menu__foot a {
  color: var(--ink-faint);
  text-decoration: none;
  transition: color 0.3s var(--ease);
}
.menu__foot a:hover { color: var(--ink); }

/* the panel is above the masthead, so the burger has to come above it */
body.menu-open .masthead { z-index: 50; }
body.menu-open { overflow: hidden; }

/* ---------- hero --------------------------------------------------- */

.hero {
  position: relative;
  /* svh is the viewport with the browser's chrome at its largest, which is
     the safe assumption for content that must not be cut — but it is the
     wrong one for a section whose whole job is to fill the screen. In an
     in-app browser, opened from a link in Telegram or WhatsApp, the chrome
     is not Safari's, so svh comes up short of what is actually on screen
     and the strip below the hero shows on arrival. dvh is whatever is
     really visible. The svh line stays under it for anything that cannot
     read dvh. */
  min-height: 100svh;
  min-height: 100dvh;
  overflow: hidden;
  background: var(--void);
}

/* the scene fills the section and owns the pointer */
.hero__scene {
  position: absolute;
  inset: 0;
  z-index: 0;
}
.hero__scene canvas { display: block; width: 100%; height: 100%; }

/* The phone's artwork, named only inside the phone's media query so no
   other screen ever fetches it. */
.hero__still { display: none; }

/* the starfield meets the black page on a gradient, not on an edge */
.hero__fade {
  position: absolute;
  inset: auto 0 0 0;
  height: clamp(7rem, 26svh, 17rem);
  z-index: 1;
  pointer-events: none;
  background: linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, #000 94%);
}

/* Copy sits left so it never covers the reveal. Nothing is drawn behind
   it — the artwork is already black where the type lands. */
.hero__content {
  position: absolute;
  z-index: 2;
  left: var(--gutter);
  top: 50%;
  transform: translateY(-50%);
  width: min(52rem, 52vw);
  text-align: left;
  pointer-events: none;
}

.hero__title {
  margin: 0;
  font-family: var(--pixel);
  font-weight: 400;
  font-size: clamp(2.35rem, 6vw, 5.3rem);
  /* Set solid. The leading used to be carrying two jobs — the gap between
     the lines, and the room .line needs under the baseline before its clip
     cuts a descender — so closing the gap cut the y in Reality. .line pays
     for the descender itself now, and this only has to answer for the
     gap. */
  line-height: 1;
  letter-spacing: 0;
  /* the 0.06em that used to sit under the last line, kept here now that
     .line contributes none — and it stops .line's negative margin
     collapsing out through the bottom of the heading */
  padding-bottom: 0.06em;
}

/* The padding is clip room, not spacing: it opens the box under the
   baseline so a descender survives the reveal, and the equal negative
   margin takes all of it back out of the layout. So the gap between the
   two lines is the leading and nothing else, and the paragraph below does
   not move. */
.line {
  display: block;
  overflow: hidden;
  padding-bottom: 0.16em;
  margin-bottom: -0.16em;
}
.line__inner {
  display: block;
  white-space: nowrap;   /* each line breaks where the markup says, not where the box ends */
  transform: translateY(110%);
  transition: transform 1.15s var(--ease-out);
}
.line:nth-child(2) .line__inner { transition-delay: 0.09s; }
.is-ready .line__inner { transform: translateY(0); }

.hero__sub {
  max-width: 42ch;
  margin: clamp(1.25rem, 2.2vw, 1.75rem) 0 0;
  color: var(--ink-dim);
  font-size: clamp(0.95rem, 1.1vw, 1.075rem);
  line-height: 1.62;
  text-wrap: pretty;
}

.hero__sub,
.cta {
  opacity: 0;
  transform: translateY(1.15rem);
  transition: opacity 1s var(--ease-out), transform 1s var(--ease-out);
}
.hero__sub { transition-delay: 0.34s; }
.cta       { transition-delay: 0.46s; }
.is-ready .hero__sub,
.is-ready .cta { opacity: 1; transform: none; }

/* ---------- call to action ----------------------------------------- */

.hero__action {
  position: absolute;
  z-index: 2;
  left: 0;
  right: 0;
  bottom: clamp(3rem, 9vh, 6.5rem);
  display: flex;
  justify-content: center;
  pointer-events: none;
}

/* A pill that fills from the left on hover. The fill is a layer behind
   the label rather than a background on the button, which is what lets
   it animate its own width while the type stays put.

   The reference is built for a light page — its raised look is a pale
   grey shadow against white. Inverted here: black where the light falls
   away, a violet lift where it catches. */

.cta {
  position: relative;
  overflow: hidden;            /* the fill is a rectangle; this rounds it */
  z-index: 1;                  /* contains the fill, which sits at -1 */
  pointer-events: auto;
  display: inline-flex;
  align-items: center;
  gap: 0.7rem;
  height: 3em;
  padding: 0 2.2em;
  border: 1px solid rgba(230, 196, 250, 0.38);
  border-radius: 30em;
  color: var(--violet-hot);
  font-family: var(--body);
  font-size: 15px;
  font-weight: 500;
  letter-spacing: 0.012em;
  text-decoration: none;
  box-shadow:
    6px 6px 16px rgba(0, 0, 0, 0.8),
    -6px -6px 16px rgba(155, 59, 224, 0.14);
  transition:
    color 0.5s ease,
    border-color 0.5s ease,
    box-shadow 0.5s ease,
    opacity 1s var(--ease-out) 0.46s,
    transform 1s var(--ease-out) 0.46s;
}

.cta::before {
  content: "";
  display: block;
  position: absolute;
  top: 0;
  left: 0;
  width: 0;
  height: 100%;
  border-radius: 30em;
  background-image: linear-gradient(to right, var(--violet) 0%, var(--violet-hot) 100%);
  /* The fill carries its own light. A box-shadow only draws outside the
     box, and the pill clips everything past its own edge, so all that
     survives is a halo just ahead of the advancing edge — the glow ends
     up travelling with the fill for free. It is tinted to the light end
     of the gradient, which is the colour that edge always is. */
  box-shadow: 0 0 1.5em 0.3em rgba(230, 196, 250, 0.42);
  transition: 0.5s ease;
  z-index: -1;
}

.cta:hover::before,
.cta:focus-visible::before { width: 100%; }

/* once the violet has arrived under it, the label has to invert to stay
   readable — the reference never faces this, its fill is pale */
.cta:hover,
.cta:focus-visible {
  color: var(--void);
  border-color: transparent;
  /* and the pill lights the page around it, ramping over the same 0.5s
     so the outward glow arrives exactly as the fill lands */
  box-shadow:
    6px 6px 16px rgba(0, 0, 0, 0.8),
    0 0 1.3em -0.1em rgba(155, 59, 224, 0.5),
    0 0 3em 0.15em rgba(155, 59, 224, 0.22);
}

.cta:focus-visible { outline: 2px solid var(--violet-hot); outline-offset: 3px; }

.cta__arrow {
  display: grid;
  place-items: center;
  width: 1.05rem;
  height: 1.05rem;
  transition: transform 0.45s var(--ease-out);
}
.cta__arrow svg { width: 100%; height: 100%; }
.cta:hover .cta__arrow { transform: translateX(0.22rem); }

/* ---------- narrow screens ----------------------------------------- */

@media (max-width: 900px) {
  .hero__content { width: min(46ch, 78vw); top: 42%; }
}

@media (max-width: 700px) {
  /* The scene is a reveal, and a reveal wants a cursor. The phone gets a
     still of the same subject instead — drawn for this shape rather than a
     wide frame squeezed into it — and the scene is not built at all. The
     check that stops it being fetched reads this rule; see index.html. */
  .hero__scene { display: none; }

  /* The frame is a touch wider than a phone is, so covering shows all of it
     top to bottom and takes the crop off the two sides, where there is
     nothing but dust. Nothing is cut off the subject at any phone size.
     It hangs a little below the top of the screen, which puts the disc in
     the middle band the copy leaves for it. The plain url is for anything
     that cannot read image-set. */
  .hero__still {
    position: absolute;
    top: clamp(0.35rem, 1.6svh, 1.25rem);
    right: 0;
    bottom: auto;
    left: 0;
    height: 100%;
    z-index: 0;
    display: block;
    background-image: url("../assets/hero-mobile-1242.jpg");
    background-image: image-set(
      url("../assets/hero-mobile-828.jpg") 2x,
      url("../assets/hero-mobile-1242.jpg") 3x);
    background-position: center;
    background-size: cover;
    background-repeat: no-repeat;
  }

  /* The frame goes to black under the masthead and comes back by the time
     the disc starts, so the top of the screen is page rather than picture
     and the heading is never set on dust. */
  .hero__still::after {
    content: "";
    position: absolute;
    inset: 0 0 auto 0;
    height: 34%;
    background: linear-gradient(to bottom,
      #000 0%,
      rgba(0, 0, 0, 0.86) 26%,
      rgba(0, 0, 0, 0.45) 58%,
      rgba(0, 0, 0, 0) 100%);
  }

  /* Nothing crosses the disc, and the disc sits between the two blocks of
     copy at the same distance from each. The three offsets here are one
     measurement, not three: the heading comes down far enough to clear the
     masthead, the paragraph is held off the foot of the hero, and where
     they land is what makes the gap above the disc and the gap below it
     equal. Moving one without the others closes that. */
  .hero__content {
    top: clamp(7rem, 18svh, 11rem);
    bottom: clamp(6.5rem, 15svh, 9.5rem);
    left: var(--gutter);
    right: var(--gutter);
    width: auto;
    transform: none;
    display: flex;
    flex-direction: column;
  }
  .hero__sub { margin-top: auto; }

  /* and the button sits clear of the paragraph rather than under it */
  .hero__action { bottom: clamp(1.75rem, 5svh, 3.5rem); }
}

@media (max-width: 560px) {
  .hero__content { width: auto; right: var(--gutter); }
  .hero__title   { letter-spacing: -0.028em; }
  .hero__sub     { max-width: 34ch; }
  .cta           { padding: 0.9rem 1.3rem 0.9rem 1.5rem; }
}

/* ===================================================================
   Selected work

   One stage, pinned. The track is tall; the stage sticks for its whole
   length, so nothing moves while you scroll through the work. Scroll
   does not scrub the artwork — it only picks which project is current.
   The dissolve between two projects is a fixed-length animation that
   always finishes, so the page can never rest inside the pixels. Timing
   and painting both live in work.js.
   =================================================================== */

/* ---------- section labels ------------------------------------------ */

/* One heading per section, so the page says what it is on the way down.
   Set in the display face at the hero's proportions, a step down from it:
   large enough to read as a title rather than as a caption. Shared by the
   pinned work stage and the about field. */
.section-label {
  margin: 0;
  font-family: var(--pixel);
  font-weight: 400;
  font-size: clamp(1.75rem, 3.4vw, 3.1rem);
  line-height: 1.05;
  letter-spacing: 0;
  color: var(--ink);
}

/* Heading, rule and copy are one block, centred in the column beside the
   card. The heading is deliberately close to the paragraph: it reads as
   the title of the copy under it rather than as a page furniture. */
.work__copy {
  position: relative;
  z-index: 3;
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-width: 0;
}

.work__label {
  font-size: clamp(2.4rem, 5.4vw, 4.6rem);
  margin-bottom: clamp(0.7rem, 1.5vh, 1.1rem);
}

/* Runs the width of the copy under it, and no further. */
.work__rule {
  width: 100%;
  max-width: 44ch;
  height: 0;
  margin: 0 0 clamp(1rem, 2.2vh, 1.6rem);
  border: 0;
  border-top: 1px solid var(--rule);
}

.work {
  position: relative;
  background: var(--void);
  /* black between the hero and the first cover, so the stage arrives
     rather than being pushed straight up against the reveal. Padding sits
     outside the track, so it adds run-up without touching the pin. */
  padding-top: clamp(7rem, 26svh, 18rem);
}

/* scroll distance for the pin: about one screen per project, plus the
   trailing hold that lets the last project sit before the stage releases */
.work__track {
  height: calc((var(--n) + 0.5) * 92svh);
}

.work__stage {
  position: sticky;
  top: 0;
  height: 100svh;
}

/* roughly 30 / 70 — the copy column is narrow so the card can be large */
.work__inner {
  position: relative;
  height: 100%;
  display: grid;
  grid-template-columns: minmax(0, 0.43fr) minmax(0, 1fr);
  gap: clamp(1.25rem, 2.6vw, 3rem);
  padding-inline: var(--gutter);
}

/* ---------- panel --------------------------------------------------- */

/* every project's copy is stacked in the same cell; only one is lit */
.work__panel {
  position: relative;
  display: grid;
  min-width: 0;
}

.project {
  grid-area: 1 / 1;
  display: grid;
  align-content: start;
  min-width: 0;
  opacity: 0;
  visibility: hidden;
  /* visibility is held back until the fade is over, or the outgoing copy
     would vanish on the first frame instead of fading */
  transition: opacity 0.28s var(--ease), visibility 0s linear 0.28s;
}
/* leaving fades; arriving does not — the shuffle is the entrance, so the
   copy is simply there the instant its characters start rolling in */
.project.is-active {
  opacity: 1;
  visibility: visible;
  transition: none;
}

/* ---------- shuffle ------------------------------------------------- */

/* Each character gets a fixed-width slot with a strip of glyphs behind it:
   two scrambles and then the real one. The strip starts two slots to the
   left and slides home, so the character rolls out of noise into itself.
   Slots are built and torn down in work.js. */

.sfl-word  { display: inline-block; white-space: nowrap; }
.sfl-char  { display: inline-block; text-align: center; }

.sfl-slot {
  display: inline-block;
  overflow: hidden;
  vertical-align: bottom;
}

.sfl-strip {
  display: inline-block;
  white-space: nowrap;
  will-change: transform;
}

/* the name is carried for structure and search, not shown — the cover
   and the copy do the identifying */
.project__name {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.project__body {
  padding-right: clamp(0.5rem, 1.5vw, 1.5rem);
  max-width: 44ch;
}

.project__body p {
  margin: 0;
  color: var(--ink-dim);
  font-size: clamp(1.02rem, 1.28vw, 1.28rem);
  line-height: 1.58;
  text-wrap: pretty;
}

/* two classes, so it outranks `.project__body p` without !important */
.project__body .project__services {
  margin-top: clamp(1rem, 2.4vh, 1.6rem);
  font-family: var(--display);
  font-size: 0.78rem;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

/* ---------- media --------------------------------------------------- */

/* ---------- the deck -------------------------------------------------- */

/* The cards are stacked in real depth: perspective on the deck, and each
   card pushed back along z by how far it is from the current one, so the
   shrink is the perspective doing it rather than a scale faking it.
   Vertical inset is larger than horizontal — the card is a card, with the
   neighbours' rounded edges showing in the space above and below. */

.deck {
  --lens: clamp(4rem, 5.07vw, 5.67rem);
  --card-r: clamp(10px, 0.85vw, 16px);
  position: relative;
  height: 100%;
  perspective: 1400px;
  /* Flat on purpose, and it must stay that way. The perspective above is
     what draws the depth; the cards are the leaves of it, so preserve-3d
     added nothing to the picture — the two render pixel for pixel alike.
     What it did add was a 3D rendering context, and inside one Chrome
     stops hit-testing a card the moment its own z is anything but zero.
     Since a card only sits at z 0 when the deck is resting exactly on it,
     every click landed on .deck instead and the cover opened nothing. */
  transform-style: flat;
}

.card {
  position: absolute;
  left: 0;
  right: 0;
  top: 13%;
  height: 74%;
  overflow: hidden;
  border-radius: var(--card-r);
  background: #0C0C0F;
  will-change: transform, opacity;
  /* only the card in front takes the pointer */
  pointer-events: none;
}
.card.is-current { pointer-events: auto; }

.card img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* A cover that moves sits over its own still, at the same crop. It is
   positioned, so it would otherwise paint over the blocks below — it waits
   for `.is-sharp` alongside the image it covers. */
.card__motion {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}
.card:not(.is-sharp) .card__motion { visibility: hidden; }

/* The pixelated stand-in. Its buffer is only as many pixels as the card
   is showing blocks — the browser does the upscale, and `pixelated`
   makes it nearest-neighbour, so the blocks stay hard-edged for free.
   Once a card has resolved it hands over to the image and gets out of
   the way, so the front card is always the real thing at full res. */
.card__px {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  image-rendering: pixelated;
}

.card.is-sharp .card__px { display: none; }
.card:not(.is-sharp) img { visibility: hidden; }

/* The lens is the cursor while you are over the card in front. Hung off
   the deck rather than off `.is-current`, so a frame in which work.js has
   not marked a card cannot flash the arrow back.

   The descendants are named too, and they have to be. A card is an <a>,
   and the browser's own stylesheet points a hand at every link — a rule
   sitting directly on the element, which an inherited value never beats.
   Setting this on the deck alone leaves that hand riding on top of the
   glass. Naming the subtree puts an author rule on the card itself, and
   author beats user-agent whatever the specificity says. */
.lens-on .deck,
.lens-on .deck * { cursor: none; }

.lens {
  position: absolute;
  left: 0;
  top: 0;
  z-index: 200;
  width: var(--lens);
  height: var(--lens);
  margin: calc(var(--lens) / -2) 0 0 calc(var(--lens) / -2);
  pointer-events: none;
  opacity: 0;
  /* transform belongs to lens.js — it tracks the pointer, so the only
     thing left for CSS to animate is the fade */
  transition: opacity 0.3s var(--ease);
  will-change: transform, opacity;
}
.lens.is-on { opacity: 1; }

.lens__gl {
  display: block;
  width: 100%;
  height: 100%;
}

/* ---------- narrow screens ------------------------------------------ */

@media (max-width: 900px) {
  /* ---------- the phone: a gallery, not a deck -----------------------

     Nothing is pinned here and nothing is switched. The covers are a
     column you scroll at whatever pace you scroll at, because a deck that
     answers a flick by playing four projects in a row is unreadable on a
     touch screen — and a section that holds the page still while it does
     it is worse. What is left for work.js is to keep the description
     pointed at the cover you have reached; it reads --deck-mode below and
     stands down from everything else. */

  .work__track { height: auto; }

  .work__stage {
    position: static;
    height: auto;
  }

  .work__inner {
    display: block;
    padding: 0 var(--gutter) clamp(2.5rem, 8vh, 5rem);
  }

  /* The heading and the description stay with you while their covers go
     past underneath. The block carries the page ground edge to edge and
     fades out just below itself, so a cover passing under it goes rather
     than being cut off against nothing. */
  .work__copy {
    position: sticky;
    top: 0;
    background: var(--void);
    margin-inline: calc(var(--gutter) * -1);
    /* held near the top of the screen: every pixel this block does not
       take is a pixel of cover you can see under it */
    padding: clamp(1.4rem, 4vh, 2.5rem) var(--gutter) clamp(0.6rem, 1.6vh, 1rem);
    justify-content: flex-start;
  }
  .work__copy::after {
    content: "";
    position: absolute;
    inset: 100% 0 auto 0;
    height: clamp(1.25rem, 3vh, 1.75rem);
    background: linear-gradient(to bottom, var(--void), rgba(0, 0, 0, 0));
    pointer-events: none;
  }

  .work__label { font-size: clamp(2rem, 8.6vw, 3.1rem); }

  /* every project's copy is stacked in this one cell, so it is as tall as
     the longest of them and never resizes on a change */
  .work__panel { padding-inline: 0; }
  .project { align-content: start; }
  .project.is-active { transition: opacity 0.3s var(--ease); }

  .project__body {
    max-width: none;
    padding-right: 0;
  }
  .project__body p { font-size: clamp(0.95rem, 3.4vw, 1.05rem); }

  /* Wider than the copy: the cover is the subject of this section. */
  .deck {
    --deck-mode: gallery;
    position: static;
    height: auto;
    perspective: none;
    /* Flattened, and not only for tidiness. There is no depth left here to
       preserve — the covers are a column in the flow. But a preserve-3d
       box is a 3D rendering context, and WebKit composites one of those as
       a single unit that can land in front of a sticky sibling whatever
       the paint order says: the covers come out over the heading that is
       meant to be holding above them, and the ones already scrolled past
       pile up at the top of the screen. Chrome does not do it, which is
       why this only ever showed on a phone. */
    transform-style: flat;
    display: grid;
    /* Wider than it was. The covers are meant to read as separate things
       set back from one another, and at the old gap they crowded into one
       another instead. */
    gap: clamp(2.25rem, 7vh, 4rem);
    margin-inline: calc(0.9rem - var(--gutter));
    padding-top: clamp(1rem, 3vh, 1.75rem);
  }

  /* In the flow, at the shape the case page opens into.

     Relative, not static: .card__motion is absolute and insets to 0, so
     the card has to stay its containing block. Left static, the clip
     resolved against an ancestor instead — every cover's video escaped
     to the full width and height of the deck, sat over the covers above
     and below it, and took the taps meant for them. Only Ar/Cut and
     KOMNATA showed it, because they are the ones with a clip either
     side. In a grid cell relative and static lay out identically, so
     this costs nothing. */
  .card {
    position: relative;
    width: auto;
    height: auto;
    /* The covers are 1600x1000. Held at 4/3 they lost 17% of their width
       to the crop, which is what made them read as blown up, and at 301px
       they ate a third of the screen each. Given the shape they actually
       are, nothing is cut and the whole cover is 50px shorter. */
    aspect-ratio: 16 / 10;
    opacity: 1;
    pointer-events: auto;
    will-change: auto;
  }

  /* No cover waits its turn as blocks: resolving five of them while a
     flick goes past is the noise that made this section hard to read.
     Stated here rather than left to work.js, so it holds either way. */
  .card__px { display: none; }
  .card:not(.is-sharp) img { visibility: visible; }
  .card:not(.is-sharp) .card__motion { visibility: visible; }
}

/* ===================================================================
   About

   One image and one button. The image is not an image on screen: it is
   a point per device pixel on a WebGL canvas that the cursor pushes
   around, with the <img> underneath as the source and the fallback.

   Full bleed, because the field feathers out at every edge instead of
   ending on a cut — there is no boundary left to align to a viewport.
   See about.js.
   =================================================================== */

.about {
  position: relative;
  background: var(--void);
  /* held off the edges, so the field reads as set into the page rather
     than bled to it — the feather does the rest */
  padding: clamp(3rem, 9vh, 7rem) clamp(1.5rem, 7vw, 9rem);
  /* The canvas below deliberately overhangs the frame, and the frame is
     already the full width of the screen, so the overhang is nothing but
     width the page does not have. Clipped here rather than left to the
     body: a document that is wider than the window is a document that
     pans sideways, which is the other half of holding the page still. */
  overflow-x: clip;
}

.about__wrap {
  /* how far the field has faded to nothing by, on each side: it tracks
     FEATHER_X in about.js, and is what the button lines up to */
  --about-edge: 11%;

  max-width: 1680px;
  margin-inline: auto;
}

/* Centred in the section and sitting *on* the picture: the negative margin
   pulls the field up under it, so the top row of points crosses the type
   instead of stopping short of it. */
.about__wrap > .section-label {
  position: relative;
  z-index: 2;
  text-align: center;
  font-size: clamp(2.4rem, 5.4vw, 4.6rem);
  margin-bottom: clamp(-4.5rem, -4vw, -1.2rem);
}

/* Height is set rather than an aspect ratio: the source is drawn to
   cover whatever box this ends up being, and the feathered edges hide
   where it was cropped. */
.about__frame {
  position: relative;
  width: 100vw;
  margin-left: 50%;
  transform: translateX(-50%);
  height: clamp(17rem, 42vw, 48rem);
}

/* Copy on the left, the button held to the right and level with the
   first line of it.

   Pulled up so the first lines sit over the foot of the picture; about.js
   darkens the points behind both blocks so nothing has to compete with
   the wave. The button stops short of the frame edge because the field
   has already faded to nothing out there — lining it up with the frame
   would read as overhanging the picture. */
.about__foot {
  position: relative;
  z-index: 2;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: clamp(1.5rem, 4vw, 4rem);
  margin-top: clamp(-5rem, -5.5vw, -2rem);
  padding-inline-end: var(--about-edge);
}

.about__text {
  margin: 0;
  max-width: 54ch;
  color: var(--ink-dim);
  font-size: clamp(1rem, 1.16vw, 1.18rem);
  line-height: 1.6;
  text-wrap: pretty;
}

/* The canvas deliberately overhangs the frame on every side. Points
   scatter into that overhang during the entrance, so they arrive from
   outside the picture instead of being clipped at its edge — which is
   what made the frame visible as a box. It draws nothing out there once
   the field has settled. about.js reads these bounds off the element,
   so the overhang is set here and nowhere else. */
/* A canvas is a replaced element, so insets alone will not stretch it:
   the overhang has to be stated as a size. 160% = 100% + 30% a side. */
.about__canvas {
  position: absolute;
  left: -30%;
  top: -30%;
  width: 160%;
  height: 160%;
  display: block;
  pointer-events: none;   /* the frame below it takes the cursor */
}

.about__fallback {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}

/* The canvas sits on top and starts empty, so the image reads through it
   until the points take over. The fallback needs the feather in CSS,
   since the one on the canvas lives in the shader. */
.about__fallback {
  object-fit: cover;
  transition: opacity 0.8s var(--ease);
  -webkit-mask-image:
    linear-gradient(to bottom, transparent, #000 14%, #000 86%, transparent);
          mask-image:
    linear-gradient(to bottom, transparent, #000 14%, #000 86%, transparent);
}
.about__frame.is-live .about__fallback { opacity: 0; }

.about__action {
  flex: none;
}

/* Both held back until the points have found their places — but only once
   about.js has said it is actually going to place them. It bows out on
   several paths (no WebGL2, a shader that will not link, reduced motion),
   and hiding the copy by default meant every one of those took the
   paragraph and the button with it. The entrance is the enhancement; the
   words are not. */
.about.is-armed .about__text,
.about.is-armed .about__action {
  opacity: 0;
  transform: translateY(0.75rem);
  transition:
    opacity 0.9s var(--ease-out) 1.3s,
    transform 0.9s var(--ease-out) 1.3s;
}
.about.is-in .about__text,
.about.is-in .about__action { opacity: 1; transform: none; }

/* the button carries its own entrance in the hero; here the wrapper does it */
.about__action .cta { opacity: 1; transform: none; transition-delay: 0s; }

@media (max-width: 760px) {
  /* The field is the section here, so it is given the height to be one:
     a wide source in a narrow box crops to the middle of the group, and
     the only way to read faces at this width is to let it get taller.
     The band of black around it comes in to match — the picture already
     fades out at its own edges, and the padding was doubling that. */
  .about { padding-block: clamp(2rem, 5.5vh, 4rem); }
  .about__frame { height: clamp(19rem, 92vw, 32rem); }

  .about__wrap > .section-label { margin-bottom: clamp(-3.25rem, -7vw, -1rem); }

  .about__foot {
    flex-direction: column;
    align-items: flex-start;
    gap: clamp(1.25rem, 3vh, 2rem);
    padding-inline-end: 0;
  }
  /* a step down, so the picture is what the section leads with */
  .about__text { font-size: clamp(0.92rem, 3.6vw, 1.02rem); }
}

/* ===================================================================
   Contact

   One drawn line separates the work from the footer. Everything below
   it sits close to it, and the mark closes the page at scale.
   =================================================================== */

.contact {
  position: relative;
  background: var(--void);
  padding-bottom: clamp(1.5rem, 4vh, 3rem);
}

/* the horizon, now just the line itself: a soft rule that fades out at
   both ends rather than meeting the edges */
.contact__horizon {
  height: 1px;
  background: linear-gradient(
    90deg,
    rgba(255, 255, 255, 0) 0%,
    rgba(255, 255, 255, 0.28) 26%,
    rgba(255, 255, 255, 0.6) 50%,
    rgba(255, 255, 255, 0.28) 74%,
    rgba(255, 255, 255, 0) 100%);
}

.contact__grid {
  display: grid;
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr) minmax(0, 1fr);
  gap: clamp(2rem, 5vw, 4.5rem);
  padding: clamp(2rem, 5.5vh, 3.5rem) var(--gutter) clamp(1.25rem, 3.5vh, 2.25rem);
}

.contact__title {
  margin: 0 0 clamp(1.75rem, 4.5vh, 3rem);
  font-family: var(--pixel);
  font-weight: 400;
  font-size: clamp(1.75rem, 2.9vw, 2.65rem);
  letter-spacing: 0;
}

.contact__label {
  margin: 0 0 clamp(1rem, 2.4vh, 1.6rem);
  font-family: var(--display);
  font-weight: 400;
  font-size: clamp(1.05rem, 1.6vw, 1.4rem);
  color: var(--ink-faint);
  letter-spacing: -0.015em;
}

/* Both columns are the same list: one destination per line, set the same
   way, whether it is a section of this page or somewhere else entirely. */
.contact__links {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: 0.75rem;
}
.contact__links a {
  color: var(--ink-dim);
  font-size: clamp(0.95rem, 1.15vw, 1.1rem);
  text-decoration: none;
  transition: color 0.3s var(--ease);
}
.contact__links a:hover { color: var(--ink); }

.contact__mark {
  display: flex;
  justify-content: center;
  padding: 0 var(--gutter);
}

/* ---------- the deforming mark --------------------------------------- */

/* The box is deliberately larger than the mark it draws: the mesh pushes
   vertices outward, and anything past the canvas edge is cut off. The
   mark is painted at MESH_FIT of this box (see mesh.js), so the slack
   around it is the room the distortion has to move into. */
.mesh {
  position: relative;
  width: min(100%, 62rem);
  height: clamp(10rem, 24vw, 21rem);
}

.mesh__canvas {
  display: block;
  width: 100%;
  height: 100%;
}

/* the source and the fallback, laid out to match what the mesh draws */
.mesh__img {
  position: absolute;
  inset: 0;
  margin: auto;
  width: auto;
  height: 68%;
  opacity: 0.92;
}
.mesh.is-live .mesh__img { visibility: hidden; }

@media (max-width: 900px) {
  /* the title above, then the two columns side by side under it: two short
     lists stacked would run the footer off the bottom of the screen */
  .contact__grid {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: clamp(1.75rem, 5vh, 2.75rem) clamp(1.25rem, 5vw, 2.5rem);
  }
  .contact__grid > .contact__col:first-child { grid-column: 1 / -1; }
  .contact__title { margin-bottom: 0; }
  .mesh { height: clamp(9rem, 34vw, 15rem); }
}

/* ---------- reduced motion ------------------------------------------ */

@media (prefers-reduced-motion: reduce) {
  .line__inner { transform: none; transition: none; }
  .hero__sub,
  .cta,
  .brand {
    opacity: 1;
    transform: none;
    transition-property: border-color, background-color, box-shadow;
  }
  .cta__arrow { transition: none; }

  /* about.js bows out entirely here, so nothing ever adds .is-in */
  .about__text,
  .about__action {
    opacity: 1;
    transform: none;
    transition: none;
  }
}



/* ===================================================================
   Handoff to a case page

   Two throwaway elements that fly from where the card and its copy sit
   to where the case page will put them, plus the invisible stage the
   destination is measured on. All of it is torn down by the navigation
   that follows. See handoff.js.
   =================================================================== */

/* A card in flight is hidden by class, not by an inline style: work.js
   writes `visibility: visible` inline on every card each frame, and an
   inline style outranks a rule — unless the rule insists. */
.card.is-handoff { visibility: hidden !important; }

/* the landing page's own header would sit on top of the case view's bar */
body.case .masthead { display: none; }

/* The case view before it is committed: laid out at viewport size and
   never painted, purely so its boxes can be measured. Dropping the class
   is what turns it into the page. */
.case-view.is-measuring {
  position: fixed;
  inset: 0;
  z-index: -1;
  visibility: hidden;
  pointer-events: none;
  overflow: hidden;
}

/* unused now that the view is built for real, kept for the fallback path */
.handoff-stage {
  position: fixed;
  inset: 0;
  z-index: -1;
  visibility: hidden;
  pointer-events: none;
  overflow: hidden;
}

.handoff {
  position: fixed;
  inset: 0;
  z-index: 400;
  pointer-events: none;
}

.handoff__cover {
  position: fixed;
  margin: 0;
  object-fit: cover;
  display: block;
  will-change: left, top, width, height;
}

