/* Glass House Design Lab, V4
   Black void, acid green, cool greys. Archivo display, Inter Tight UI, JetBrains Mono chrome.
   There is no vignette, no CRT warp and no darkening of screen edges or corners anywhere in
   this file. The only full-screen overlay is uniform film grain.

   Sections:
     1  fonts + tokens            6  #work shell (canvas, HUD, list / fallback row, wipe)
     2  base + utilities          7  footer + fan-down
     3  header                    8  contact panel + form
     4  the 3D mark               9  project sheet             10 index view
     5  hero                      11 grain, focus, reduced motion                              */

/* ======================================================================= 1. fonts + tokens */

@font-face {
  font-family: 'Archivo';
  src: url('../fonts/Archivo.woff2') format('woff2');
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'Inter Tight';
  src: url('../fonts/InterTight.woff2') format('woff2');
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: 'JetBrains Mono';
  src: url('../fonts/JetBrainsMono.woff2') format('woff2');
  font-weight: 400 800;
  font-style: normal;
  font-display: swap;
}

:root {
  --black: #000;
  --void: #050505;
  --charcoal: #1a1a1a;
  --slate: #71737d;
  --mist: #d9dcdf;
  --ink: #111;
  --green: #00fd01;
  --green-ink: #04210a;
  --rule: rgba(217, 220, 223, 0.28);
  --rule-green: rgba(4, 33, 10, 0.55);

  --display: 'Archivo', 'Helvetica Neue', Arial, sans-serif;
  --ui: 'Inter Tight', 'Helvetica Neue', Arial, sans-serif;
  --mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace;

  /* (viewport-fit=cover: in landscape the gutter also clears the notch, on both sides) */
  --gutter: max(20px, 3vw, env(safe-area-inset-left), env(safe-area-inset-right));
  --safe-b: env(safe-area-inset-bottom, 0px);
  --hdr-h: 72px;
  --slot-h: 36px;
  --r-card: 18px;
  --r-sheet: 20px;

  --expo-out: cubic-bezier(0.16, 1, 0.3, 1);
  --expo-in-out: cubic-bezier(0.87, 0, 0.13, 1);
  --flight: cubic-bezier(0.16, 0.84, 0.24, 1);

  /* Header ink: light over the dark sections, dark over green (body.ink-dark). */
  --hdr-ink: var(--mist);
  --hdr-contrast: #000;
}

@media (max-width: 699px) {
  :root {
    --hdr-h: 60px;
    --slot-h: 30px;
    --r-card: 16px;
    --r-sheet: 14px;
  }
}

body.ink-dark {
  --hdr-ink: var(--green-ink);
  --hdr-contrast: var(--green);
}

/* ======================================================================= 2. base + utilities */

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

/* The page scrolls natively on every device. On a touch screen CSS decides where it comes to rest:
   each section's top is a snap point, with proximity snapping, so a swipe that stops near a
   section lands on it and one that stops well inside a tall section (the footer on a phone on its
   side) stays put. No scroll-snap-stop: a fling may pass a section. A desktop (mouse or trackpad,
   and the keyboard) scrolls freely, as any page does: under root proximity snapping Safari's
   engine turned an arrow key pressed at a section's top into a no-op, so the first ↓ on load did
   nothing. ?snap=proximity, ?snap=mandatory and ?snap=none (classes set by index.html's head
   script) are there to compare the feel on any device; they outrank the touch rule.
   No scroll-behavior here: scripted scrolls (scroll.js) ask for a glide themselves, or a jump
   when the visitor prefers reduced motion. (A smooth root made Chromium turn ScrollTrigger's
   measuring jump to the top into a smooth scroll that never ran, so a refresh while the page was
   scrolled measured the #work wipe from the wrong place.)
   Touch screens show the platform's own scroll indicator (iOS and Android draw it over the page
   only while it moves). With a mouse the page's scrollbar stays hidden, as it always has: a
   classic desktop scrollbar takes a gutter that the full-bleed sections and the overlays cannot
   paint (a black strip beside the green panel, an uneven margin round the project sheet). Wheel,
   trackpad, keys and the scroll position all stay native. The overlays' own scrollers show a
   thin scrollbar in their ink everywhere (sections 8 and 9). */
html {
  --scroll-thumb: rgba(217, 220, 223, 0.3);
  background: var(--black);
  color-scheme: dark;
  scrollbar-gutter: stable;
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}
html,
.contact__scroll,
.sheet__body,
.sheet__copy {
  scrollbar-width: thin;
  scrollbar-color: var(--scroll-thumb) transparent;
}
@media (hover: hover) and (pointer: fine) {
  html {
    scrollbar-width: none;
  }
  html::-webkit-scrollbar {
    display: none;
  }
}
@media (hover: none), (pointer: coarse) {
  html {
    scroll-snap-type: y proximity;
  }
}
/* (html.snap-* outranks the plain html rule above, on any device.) */
html.snap-proximity {
  scroll-snap-type: y proximity;
}
html.snap-mandatory {
  scroll-snap-type: y mandatory;
}
html.snap-none {
  scroll-snap-type: none;
}
main > section {
  scroll-snap-align: start;
}
/* Overlays hold the page still (GH.scroll.lock, ref-counted). */
html.is-locked {
  overflow: hidden;
}
/* The root background shows past the ends of the page (the rubber band) and wherever the page
   does not paint: green while the footer, or the green wipe leading into it, reaches the bottom of
   the viewport (main.js), black otherwise. html only: the body keeps its own black. */
html.end-green {
  background-color: var(--green);
}
/* Once the contact panel's green has covered the screen, the root goes green with it, so whatever
   Safari falls back to for its bars, and any scrollbar gutter, matches the panel. (A rule of its
   own: an engine without :has() drops the whole selector list it sits in, and html.end-green
   must not go with it.) */
html:has(.contact.is-covered) {
  background-color: var(--green);
}

body {
  margin: 0;
  background: var(--black);
  color: var(--mist);
  font-family: var(--ui);
  font-size: 16px;
  line-height: 1.4;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}

h1, h2, h3, p, ul, ol, figure {
  margin: 0;
}
ul, ol {
  padding: 0;
  list-style: none;
}
a {
  color: inherit;
  text-decoration: none;
}
button {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
img,
svg,
canvas {
  display: block;
}
/* The reset above would otherwise beat the UA's [hidden] rule (an unset <img> then paints
   Chrome's empty-image border). */
[hidden] {
  display: none !important;
}

::selection {
  background: var(--green);
  color: var(--ink);
}

.visually-hidden {
  position: absolute !important;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.sprite {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

/* Inline icons: 1.5px strokes with round caps at any size. */
.i {
  width: 1em;
  height: 1em;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-linejoin: round;
  overflow: visible;
}
.i path {
  vector-effect: non-scaling-stroke;
}

.mono {
  font-family: var(--mono);
  text-transform: uppercase;
  letter-spacing: -0.02em;
}

/* Masks for line reveals. Lines rise from 100% inside an overflow:hidden window; the small
   padding / negative margin keeps ascenders and descenders from being shaved by the mask. */
.mask,
.line {
  display: inline-block;
  overflow: hidden;
  vertical-align: top;
  padding: 0.14em 0;
  margin: -0.14em 0;
}
.line {
  display: block;
}
.mask__inner,
.line__inner {
  display: inline-block;
}
.line__inner {
  display: block;
}
.js [data-hero-lines]:not(.is-split) {
  opacity: 0;
}
.js .hero .mask__inner {
  transform: translateY(110%);
}
.js [data-hud-fade] {
  opacity: 0;
}
/* Safety net: if the modules never boot, show everything (and the work list as the fallback row,
   section 6). */
.boot-failed .mask__inner,
.boot-failed .line__inner,
.boot-failed [data-hero-lines],
.boot-failed [data-hud-fade],
.boot-failed .logo3d__stage {
  opacity: 1 !important;
  transform: none !important;
}

.skip {
  position: fixed;
  z-index: 95;
  left: var(--gutter);
  top: calc(var(--hdr-h) + 12px); /* just under the header line, clear of the wordmark */
  padding: 0.7em 1.2em;
  border-radius: 999px;
  background: var(--green);
  color: var(--green-ink);
  font: 400 12px/1 var(--mono);
  text-transform: uppercase;
  letter-spacing: -0.02em;
  transform: translateY(calc(-100% - var(--hdr-h) - 24px)); /* parked above the viewport */
  transition: transform 0.3s var(--expo-out);
}
.skip:focus {
  transform: none;
}

/* ======================================================================= 3. header */

.hdr {
  position: fixed;
  inset: 0 0 auto;
  z-index: 40;
  height: var(--hdr-h);
  color: var(--hdr-ink);
  pointer-events: none; /* only its controls take the pointer */
  transition: color 0.2s ease, opacity 0.45s ease;
}
/* One hairline in the current ink at ~28%. */
.hdr::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1px;
  background: currentColor;
  opacity: 0.28;
  pointer-events: none;
}
/* While the contact panel is open the header lives inside its dialog, above the green, with the
   pill reading "Close" (contact.js). While the wipe sweeps past it (~0.1s), the ink switches
   outright at the flood's contrast crossover instead of through a .2s mid-grey. */
body.ink-snap .hdr {
  transition-duration: 0s, 0.45s;
}
body.ink-snap .hud-quote,
body.ink-snap .hud-quote__label,
body.ink-snap .flood {
  transition-duration: 0s;
}

.hdr__inner {
  height: 100%;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  padding: 0 var(--gutter);
}

.hdr__brand {
  justify-self: start;
  display: block;
  width: clamp(112px, 11vw, 176px);
  pointer-events: auto;
  border-radius: 4px;
}
.hdr__wordmark {
  width: 100%;
  height: auto;
  aspect-ratio: 744 / 103;
  fill: currentColor;
}

/* The docked 3D mark lands here: slot height = the docked mark height. */
.hdr__slot {
  display: block;
  width: calc(var(--slot-h) * 1.91);
  height: var(--slot-h);
  border-radius: 999px;
}
.hdr:has(.logo3d:focus-visible) .hdr__slot {
  outline: 2px solid var(--green);
  outline-offset: 5px;
}
body.ink-dark .hdr:has(.logo3d:focus-visible) .hdr__slot {
  outline-color: var(--green-ink);
}

.hud-quote {
  justify-self: end;
  position: relative;
  overflow: hidden;
  isolation: isolate;
  pointer-events: auto;
  padding: 0.72em 1.3em;
  border: 1px solid var(--hdr-ink);
  border-radius: 999px;
  color: var(--hdr-ink);
  font: 400 12px/1 var(--mono);
  text-transform: uppercase;
  letter-spacing: -0.02em;
  white-space: nowrap;
  transition: border-color 0.2s ease, color 0.2s ease;
}
/* Pointer-origin flood (header.js places and scales the disc). */
.flood {
  position: absolute;
  z-index: -1;
  left: 0;
  top: 0;
  width: 0;
  height: 0;
  border-radius: 50%;
  background: var(--hdr-ink);
  transform: scale(0);
  pointer-events: none;
  transition: background-color 0.2s ease; /* re-inks with the label, not ahead of it */
}
.hud-quote__label {
  display: grid;
  transition: color 0.25s ease;
}
.hud-quote.is-flooded .hud-quote__label {
  color: var(--hdr-contrast);
}
.hud-quote__face {
  grid-area: 1 / 1;
  display: block;
  text-align: center;
}
/* "Close" waits folded away (header.js flips it in): also before the modules boot, or without them. */
.hud-quote__face[data-face='b'] {
  opacity: 0;
  transform: scaleY(0);
}
@media (max-width: 699px) {
  .hud-quote {
    font-size: 11px;
    padding: 0.75em 1.25em;
  }
}

/* ======================================================================= 4. the 3D mark */

/* Fixed; logo3d.js sets width/height and a translate+scale transform (origin top centre). */
.logo3d {
  position: fixed;
  z-index: 2;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  padding: 0;
  transform-origin: 50% 0;
  pointer-events: none;
  cursor: default;
  will-change: transform;
  transition: opacity 0.45s ease 0.15s; /* back as the contact panel draws away */
}
/* The undocked hero mark steps away while the contact panel is up (the header, which holds the
   mark, moves into the panel's dialog, above the green): it would otherwise hang over the form.
   logo3d.js sets it. */
.logo3d.is-veiled {
  opacity: 0;
  transition: opacity 0.25s ease;
}
.logo3d.is-docked {
  pointer-events: auto;
  cursor: pointer;
}
.logo3d__stage {
  position: absolute;
  inset: 0;
  display: block;
}
.js .logo3d__stage {
  opacity: 0; /* revealed once the mark is built */
}
/* The canvas holder is the box plus, below it, the hero reflection region (logo3d.js sets
   its height); the reflection is composited in WebGL, so there is no second canvas. */
.logo3d__canvas {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}
.logo3d__canvas canvas {
  width: 100%;
  height: 100%;
}
.logo3d__img {
  position: absolute;
  left: 10%;
  top: 10%;
  width: 80%;
  height: 80%;
  transition: filter 0.2s ease;
}
body.ink-dark .logo3d__img {
  filter: brightness(0);
}

/* ======================================================================= 5. hero */

.hero {
  position: relative;
  height: 100svh;
  min-height: 560px;
  background: var(--black);
}
/* The flat mark, set in the hero only if the 3D mark's module cannot load (main.js). Same
   place and size as the 3D mark: centred at 46% of the hero, ~34svh tall (phones ~26svh). */
.hero__mark-static {
  position: absolute;
  left: 50%;
  top: 46%;
  height: 34svh;
  width: auto;
  max-width: 60vw;
  object-fit: contain;
  transform: translate(-50%, -50%);
  pointer-events: none;
}
@media (max-width: 699px) {
  .hero__mark-static {
    height: 26svh;
    max-width: 78vw;
  }
}

.hero__foot {
  position: absolute;
  left: var(--gutter);
  right: var(--gutter);
  bottom: max(28px, 3vw);
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 12px 32px;
  font: 400 12px/1.5 var(--mono);
  text-transform: uppercase;
  letter-spacing: -0.02em;
  color: var(--mist);
}
.hero__tagline {
  max-width: 40ch;
}
.hero__cue {
  display: inline-flex;
  flex: none;
  border-radius: 4px;
}
.hero__cue .mask__inner {
  display: inline-flex;
  align-items: center;
  gap: 0.7em;
}
.hero__cue .i {
  transition: transform 0.6s var(--expo-out);
}
@media (hover: hover) {
  .hero__cue:hover .i {
    transform: translateY(3px);
  }
}
@media (max-width: 699px) {
  .hero__foot {
    flex-wrap: wrap;
    font-size: 11px;
    bottom: calc(20px + var(--safe-b)); /* clear of the home indicator, never flush on it */
  }
  .hero__tagline {
    flex: 1 1 100%;
    max-width: 46ch;
  }
  .hero__cue {
    margin-left: auto;
  }
}

/* ======================================================================= 6. #work */

.work {
  /* (the 62vw cap keeps a tall, narrow screen such as a portrait tablet a carousel: both
     neighbours show, rather than one card filling the width) */
  --card-h: min(46svh, 560px, calc(62vw / 1.6));
  --card-w: calc(var(--card-h) * 1.6); /* covers are 16:10 */
  --card-gap: 14px;
  position: relative;
  height: 100svh;
  /* The large viewport: once a phone's toolbar collapses, #work still fills the screen (with
     svh the green footer would show under it). lvh never changes with the toolbar. */
  height: 100lvh;
  min-height: 600px;
  background: var(--black);
  overflow: hidden;
}
@media (max-width: 699px) {
  .work {
    /* Phones show each site's real mobile layout: m1.webp cover-fit to 3:4. */
    --card-h: min(52svh, calc(78vw * 4 / 3));
    --card-w: calc(var(--card-h) * 0.75);
    --card-gap: 12px;
    min-height: 560px;
  }
}
/* With WebGL the section takes horizontal gestures itself (folio/input.js) and leaves the rest to
   the browser: vertical swipes scroll the page natively, pinches zoom, and a swipe the browser
   starts panning cancels the ribbon's pointer, which then settles on a card. The page's only
   custom touch-action. */
.gl .work {
  touch-action: pan-y pinch-zoom;
  -webkit-tap-highlight-color: transparent;
}
/* The ribbon surface is not text (a drag must not select). The Index names stay ordinary links. */
.gl .work[data-mode='featured'] {
  -webkit-user-select: none;
  user-select: none;
}
/* Ribbon cursors (folio.js): grab over the section, pointer over a pane, grabbing mid-drag. */
@media (hover: hover) and (pointer: fine) {
  .gl .work[data-mode='featured'] {
    cursor: grab;
  }
  .gl .work[data-mode='featured'].is-over-card {
    cursor: pointer;
  }
  .gl .work.is-grabbing,
  .gl .work.is-grabbing * {
    cursor: grabbing !important;
  }
}

.folio-gl {
  position: absolute;
  z-index: 1;
  inset: 0;
  width: 100%;
  height: 100%;
}
.no-gl .folio-gl,
.no-js .folio-gl {
  display: none;
}

/* --- work list: visually hidden but focusable with WebGL, the visible row without: a native
   horizontal scroller that snaps a card to the centre (workhud.js only reads and steers it) --- */

.work-list {
  z-index: 2;
}
/* Until main.js knows which of the two it is: not laid out, so its lazy images stay unloaded
   (a hidden but laid-out list sits right under the hero and would fetch every cover at boot).
   If the modules never boot (html.boot-failed, index.html) it is the row below instead: the
   cards and their images are there, and the row scrolls and snaps without any script. */
.js:not(.work-ready):not(.boot-failed) .work-list {
  display: none;
}
/* With WebGL the list is only an accessibility proxy for the ribbon: its images never load. */
.gl .work-card__media {
  display: none;
}
/* The proxy covers #work and every button sits exactly where the ribbon's centre card is drawn.
   When a button takes focus the browser reveals it natively (WebKit scrolls the focused box into
   view, centre-if-needed), and that box is now inside the framed section, so the reveal leaves
   #work framed instead of chasing a full-size card hanging off its bottom edge to the footer. The
   same rect is where sheet.js starts a keyboard-Enter flight, so it rises from the centre card.
   clip-path inset(50%) leaves no visible or hittable area: the ribbon canvas keeps every pointer. */
.gl .work-list {
  position: absolute;
  inset: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.gl .work-list .work-card {
  position: absolute;
  left: calc(50% - var(--card-w) / 2);
  top: calc(50% - var(--card-h) / 2);
}

.no-gl .work-list,
.no-js .work-list,
.boot-failed:not(.work-ready) .work-list {
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  transform: translateY(-50%);
  display: flex;
  gap: var(--card-gap);
  padding: 16px calc(50% - var(--card-w) / 2);
  overflow-x: auto;
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
  overscroll-behavior-x: contain;
}
/* With the modules running, the HUD's arrows and the ←/→ keys stand in for the row's scrollbar.
   Without them (no JS, or a failed boot) the thin scrollbar stays: a mouse has no other way along. */
.no-gl .work-list {
  scrollbar-width: none;
}
.no-gl .work-list::-webkit-scrollbar {
  display: none;
}
.work-list__item {
  flex: none;
  scroll-snap-align: center;
}

.work-card {
  position: relative;
  display: block;
  width: var(--card-w);
  height: var(--card-h);
  border-radius: var(--r-card);
  overflow: hidden;
  background: #111;
  color: #fff;
  text-align: left;
  isolation: isolate;
  transform: translateZ(0);
}
.work-card__media,
.work-card__media picture,
.work-card__media img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
.work-card__media img {
  object-fit: cover;
  object-position: 50% 0;
  transition: transform 1.2s var(--expo-out);
  -webkit-user-drag: none;
  user-select: none;
}
.work-card {
  -webkit-user-select: none;
  user-select: none;
}
/* Per-card caption scrim: darkens only the bottom of the card, never the screen. */
.work-card::after {
  content: '';
  position: absolute;
  z-index: 1;
  left: 0;
  right: 0;
  bottom: 0;
  height: 46%;
  background: linear-gradient(
    to top,
    rgba(0, 0, 0, 0.6) 0%,
    rgba(0, 0, 0, 0.36) 12%,
    rgba(0, 0, 0, 0.16) 24%,
    rgba(0, 0, 0, 0.04) 36%,
    rgba(0, 0, 0, 0) 100%
  );
  pointer-events: none;
}
/* Glass edge: a bright rim and a faint inner bevel line (as the GL panes). */
.work-card::before {
  content: '';
  position: absolute;
  z-index: 3;
  inset: 0;
  border-radius: inherit;
  box-shadow: inset 0 0 0 1.5px rgba(255, 255, 255, 0.26), inset 0 0 0 3.5px rgba(255, 255, 255, 0.07);
  pointer-events: none;
}
.work-card__cap {
  position: absolute;
  z-index: 2;
  left: 18px;
  right: 64px;
  bottom: 16px;
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.work-card__name {
  font: 600 20px/1 var(--display);
  letter-spacing: -0.02em;
  color: #fff;
}
.work-card__line {
  position: absolute !important;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.work-card__go {
  position: absolute;
  z-index: 2;
  right: 16px;
  bottom: 14px;
  width: 30px;
  height: 30px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: #fff;
  color: #000;
  font-size: 14px;
  transition: transform 0.6s var(--expo-out);
}
@media (hover: hover) and (pointer: fine) {
  .work-card:hover .work-card__media img {
    transform: scale(1.035);
  }
  .work-card:hover .work-card__go {
    transform: rotate(-45deg);
  }
}
@media (max-width: 699px) {
  /* Phones show m1 framed as the GL card frames it (data.js mobileCrop: workhud.js sets the
     vars), so no site headline sits under the caption; the caption band is frosted glass. */
  .work-card__media img {
    object-position: calc(var(--crop-x, 0.5) * 100%) calc(var(--crop-y, 0) * 100%);
    scale: var(--crop-zoom, 1);
    transform-origin: calc(var(--crop-x, 0.5) * 100%) calc(var(--crop-y, 0) * 100%);
  }
  .work-card::after {
    height: 28%;
    background: rgba(0, 0, 0, 0.55);
    -webkit-backdrop-filter: blur(14px);
    backdrop-filter: blur(14px);
  }
  .work-card__name {
    font-size: 17px;
  }
  .work-card__cap {
    left: 14px;
    bottom: 14px;
  }
  .work-card__go {
    right: 12px;
    bottom: 12px;
  }
}

/* --- green liquid wipe at the bottom of #work (scrubbed by footer.js) --- */

.work-wipe {
  position: absolute;
  z-index: 3;
  inset: 0;
  overflow: hidden;
  pointer-events: none;
}
/* The liquid wipe, shared by the #work flood and the contact panel. Layer i has depth
   --d = 3 - i: it reaches 1 + d*.13 of the box, is blurred d*4px and is 1 - d*.18 opaque,
   so the deep layers lead as a soft smear and the crisp front layer lands last. The layers
   TRANSLATE up rather than scaleY, which keeps the blur (and the soft leading edge) the same
   width at every moment instead of squashing it into hard bands. JS sets --d and the y.
   The blur is baked into the gradient: a layer is a flat green field whose only edge on screen
   is its leading one (its sides and bottom hang 64px outside the box), and blurring a d*48px
   ramp by d*4px gives exactly this profile (stops every 6d px; it starts 3 blur radii, 12d px,
   above the ramp, so the box reaches that much higher). A live blur filter on four full-screen
   layers cost a GPU shader compile per radius on the first wipe, and a re-filter per frame. */
.work-wipe > i,
.contact__wipe > i {
  --d: 0;
  position: absolute;
  left: -64px;
  right: -64px;
  bottom: -64px; /* hang below the box so the soft bottom edge is clipped */
  height: calc(100% * (1 + var(--d) * 0.13) + 64px + var(--d) * 12px);
  background: linear-gradient(
    to bottom,
    rgba(0, 253, 1, 0) 0,
    rgba(0, 253, 1, 0.002) calc(var(--d) * 6px),
    rgba(0, 253, 1, 0.033) calc(var(--d) * 12px),
    rgba(0, 253, 1, 0.127) calc(var(--d) * 18px),
    rgba(0, 253, 1, 0.25) calc(var(--d) * 24px),
    rgba(0, 253, 1, 0.75) calc(var(--d) * 48px),
    rgba(0, 253, 1, 0.873) calc(var(--d) * 54px),
    rgba(0, 253, 1, 0.967) calc(var(--d) * 60px),
    rgba(0, 253, 1, 0.998) calc(var(--d) * 66px),
    var(--green) calc(var(--d) * 72px)
  );
  opacity: calc(1 - var(--d) * 0.18);
  transform: translateY(100%);
  will-change: transform;
}

/* --- HUD --- */

.work-hud {
  position: absolute;
  z-index: 4;
  inset: 0;
  pointer-events: none;
  color: var(--mist);
  font: 400 12px/1.2 var(--mono);
  text-transform: uppercase;
  letter-spacing: -0.02em;
  /* Steps aside as the green floods up (--wipe is the wipe's scrub progress), and while a
     pane flies to / sits as the project sheet (--hud-a, tweened by folio.js). */
  opacity: calc((1 - var(--wipe, 0) * 6) * var(--hud-a, 1));
}
.work-hud button {
  pointer-events: auto;
  text-transform: inherit;
  letter-spacing: inherit;
}
/* Well below the mask's own padding: nothing peeks out before the reveal. */
.js .work-hud .mask__inner {
  transform: translateY(130%);
}
/* Secondary HUD text (the slash, the view not shown) is dimmed, but at .62 of the mist it still
   reads 5.6:1 on black (WCAG AA for small text). */
.work-hud__slash {
  opacity: 0.62;
}

.work-hud__eyebrow {
  position: absolute;
  top: calc(var(--hdr-h) + 22px);
  left: var(--gutter);
  font: inherit;
}

.work-hud__bar {
  position: absolute;
  left: var(--gutter);
  right: var(--gutter);
  bottom: max(26px, 3vh);
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 16px;
}
/* #work is 100lvh: while a phone or tablet shows its bottom toolbar, the section runs on under
   it, so the row rides above the toolbar (lvh - dvh is 0 once it has gone, and on desktops). */
@supports (height: 100dvh) {
  .work-hud__bar {
    bottom: calc(100lvh - 100dvh + max(26px, 3vh, 12px + var(--safe-b)));
  }
}

.work-hud__modes {
  justify-self: start;
  display: flex;
  align-items: center;
  gap: 0.5em;
}
.work-hud__modes button {
  position: relative;
  opacity: 0.62;
  border-radius: 3px;
  transition: opacity 0.5s ease;
}
.work-hud__modes button[aria-pressed='true'] {
  opacity: 1;
}
@media (hover: hover) {
  .work-hud__modes button:hover {
    opacity: 1;
  }
}
/* Touch (any coarse pointer: an iPad with a trackpad is still touched): each word takes a
   44px-tall target without moving the layout. */
@media (any-pointer: coarse) {
  .work-hud__modes button::after {
    content: '';
    position: absolute;
    inset: -15px -8px;
  }
}

.work-hud__centre {
  justify-self: center;
  grid-column: 2;
  position: relative;
  display: grid;
  place-items: center;
  min-width: 0;
  max-width: 100%;
}
.work-hud__hint,
.work-hud__focus {
  grid-area: 1 / 1;
  max-width: 100%;
  white-space: nowrap;
}
.work-hud__hint .mask__inner {
  display: inline-flex;
  align-items: center;
  gap: 0.8em;
}
.work-hud__hint .i {
  opacity: 0.6;
}
.work-hud__hint-touch {
  display: none;
}
/* "Swipe" wherever a finger moves the ribbon (no hover or a coarse pointer, and every phone-width
   screen), "Drag" for a mouse. */
@media (hover: none), (pointer: coarse), (max-width: 699px) {
  .work-hud__hint-mouse {
    display: none;
  }
  .work-hud__hint-touch {
    display: inline;
  }
}
/* "deer + almond — Enter to open" for a keyboard-focused card (the name only on phones). */
.work-hud__focus {
  overflow: hidden;
  text-overflow: ellipsis;
  opacity: 0;
  transform: translateY(6px);
  transition: opacity 0.35s ease, transform 0.6s var(--expo-out);
}
.work-hud.is-focus .work-hud__focus {
  opacity: 1;
  transform: none;
}
.work-hud.is-focus .work-hud__hint {
  opacity: 0 !important;
}

.work-hud__nav {
  justify-self: end;
  grid-column: 3;
  display: flex;
  align-items: center;
  gap: 8px;
  transition: opacity 0.5s ease;
}
.work-hud__btn {
  width: 36px;
  height: 36px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  border: 1px solid var(--rule);
  font-size: 14px;
  transition: background-color 0.35s ease, color 0.35s ease, border-color 0.35s ease;
}
@media (any-pointer: coarse) {
  .work-hud__btn {
    width: 44px;
    height: 44px;
  }
}
@media (hover: hover) {
  .work-hud__btn:hover {
    background: var(--mist);
    border-color: var(--mist);
    color: #000;
  }
}
.work-hud__btn:active {
  transform: scale(0.94);
}
/* The Index view has no "current card": the arrows step away (workhud.js makes them inert). */
.work[data-mode='index'] .work-hud__nav,
.work[data-mode='index'] .work-hud__centre {
  opacity: 0;
}

/* Phones: two groups in the row, Featured / Index on the left and the arrows on the right; the
   hint (and a keyboard focus label) floats centred just above the row. */
@media (max-width: 699px) {
  .work-hud {
    font-size: 11px;
  }
  .work-hud__eyebrow {
    top: calc(var(--hdr-h) + 18px);
  }
  .work-hud__bar {
    grid-template-columns: auto 1fr auto;
    bottom: calc(16px + var(--safe-b));
  }
  @supports (height: 100dvh) {
    .work-hud__bar {
      bottom: calc(100lvh - 100dvh + 16px + var(--safe-b));
    }
  }
  .work-hud__centre {
    position: absolute;
    grid-column: 1 / -1; /* (an absolute grid child: the whole row is its box) */
    left: 0;
    right: 0;
    bottom: calc(100% + 16px);
  }
  .work-hud__focus-hint {
    display: none;
  }
  .work-hud__nav {
    gap: 10px;
  }
}

/* ======================================================================= 7. footer + fan-down */

.footer {
  position: relative;
  min-height: 100svh;
  /* The dynamic viewport: exactly one screen whether a phone's toolbar shows or not, so at the
     end of the page the footer fills the screen (with svh the end of #work showed above it once
     the toolbar collapsed), its wordmark stays clear of the header and its bottom row sits just
     above the toolbar (with lvh the footer ran 86px past a showing toolbar). The page rests at
     the same scroll position in both states. */
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: var(--green);
  color: var(--green-ink);
}
.footer ::selection,
.contact ::selection {
  background: var(--green-ink);
  color: var(--green);
}

.footer__stack {
  --trail-progress: 0;
  --trail-breath: 1;
  --trail-count: 14; /* (footer.js sets it from its TRAILS) */
  /* The farthest copy falls this far below the front wordmark, in its own heights, and the copies
     share the fall evenly, so each overlaps the next (14 over 2.4 heights: ~6 deep at any point). */
  --trail-reach: 240%;
  --trail-density: 0.13; /* the nearest copy's opacity: low, as several copies overlap everywhere */
  --trail-blur: 12px; /* the farthest copy's blur (footer.js bakes the same: TRAIL_BLUR) */
  --stack-top: max(96px, 9vw);
  position: relative;
  isolation: isolate;
  padding: var(--stack-top) var(--gutter) 0;
}
.footer__logo {
  width: 100%;
  height: auto;
  aspect-ratio: 744 / 103;
  fill: currentColor;
}
.footer__logo--front {
  position: relative;
  z-index: 10;
}
/* The reflective fan-down: overlapping copies, each a step further down, more blurred and
   fainter than the last, at the wordmark's true proportions (never stretched), fading out on a
   smooth curve (--trail-fade, set per copy by footer.js). The breath (scroll velocity) is the
   subtle touch; nothing follows the pointer. */
.footer__logo--trail {
  --u: calc(var(--trail-index) / var(--trail-count)); /* 0 at the front .. 1 for the farthest */
  --p: calc(var(--trail-progress) * var(--trail-breath));
  position: absolute;
  left: var(--gutter);
  top: var(--stack-top);
  width: calc(100% - 2 * var(--gutter));
  color: var(--green-ink);
  transform-origin: 50% 0;
  transform: translateY(calc(var(--p) * var(--u) * var(--trail-reach)));
  opacity: calc(var(--trail-density) * var(--trail-fade, 1) * var(--trail-progress));
  /* The live blur stands in until footer.js has baked the levels below (it costs the GPU a shader
     program per radius met, and a re-filter of every copy on every frame of the fan-out). */
  filter: blur(calc(var(--trail-progress) * var(--u) * var(--trail-blur)));
  will-change: transform, opacity, filter;
  pointer-events: none;
}
.footer__trail-sharp {
  display: block;
  width: 100%;
  height: 100%;
  fill: currentColor;
}
/* The baked blur: level k of LEVELS (3) is the copy blurred by u * --trail-blur * k/3, so the
   live blur(p * u * --trail-blur) sits at x = 3p along sharp (0), 1, 2, 3. The two levels either side of x crossfade, added
   together (plus-lighter, inside the copy's own isolated group) so the mix is linear. At full
   progress only level 3 shows and plain blending does. --pad is each canvas's blur room. */
.footer__trail-level {
  display: none;
  position: absolute;
  left: calc(var(--pad, 0px) * -1);
  top: calc(var(--pad, 0px) * -1);
  width: calc(100% + var(--pad, 0px) * 2);
  height: calc(100% + var(--pad, 0px) * 2);
  mix-blend-mode: plus-lighter;
}
.footer__stack.is-baked .footer__logo--trail {
  --x: calc(var(--trail-progress) * 3);
  isolation: isolate;
  filter: none;
  will-change: transform, opacity;
}
.footer__stack.is-baked .footer__trail-sharp {
  opacity: max(0, 1 - var(--x));
}
.footer__stack.is-baked .footer__trail-level {
  display: block;
  opacity: max(0, min(var(--x) - var(--k) + 1, var(--k) + 1 - var(--x)));
}
.footer__stack.is-baked.is-full .footer__trail-level {
  mix-blend-mode: normal;
}
/* Portrait screens: the wordmark is short against a tall footer, so the fan falls much further
   (each copy about half a wordmark below the last, so ~2 deep at any point). */
@media (orientation: portrait) {
  .footer__stack {
    --trail-reach: 720%;
    --trail-density: 0.3;
  }
}
@media (max-width: 699px) {
  .footer__stack {
    --stack-top: calc(var(--hdr-h) + 40px);
  }
}

.footer__base {
  position: relative;
  z-index: 11;
  margin-top: auto;
  display: grid;
  grid-template-columns: 1fr 2fr;
  align-items: end;
  padding: 48px var(--gutter) max(3vw, 24px);
}
.footer__barcode {
  display: flex;
  align-items: flex-end;
}
.footer__barcode svg {
  width: clamp(140px, 52%, 180px);
  height: auto;
}
.footer__barcode text {
  font-family: var(--mono);
}

.footer__rows {
  display: flex;
  flex-direction: column;
  font: 400 clamp(14px, 1.2vw, 18px) / 1.2 var(--mono);
  letter-spacing: -0.02em;
}

/* The old .button__half row: a full-width line between hairlines; the ink wipes up from
   the bottom on hover and leaves through the top (footer.js drives --anim / origin). */
.button__half {
  --anim: 0;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  padding: 0.85em 0;
  border-bottom: 1px solid var(--rule-green);
  color: var(--green-ink);
  text-align: left;
}
.button__half:first-child {
  border-top: 1px solid var(--rule-green);
}
.button__half::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: calc(100% + 1px);
  background: var(--green-ink);
  transform: scaleY(var(--anim));
  transform-origin: 50% var(--button-origin, 100%);
  pointer-events: none;
}
.button__half > span {
  position: relative;
  display: inline-flex;
  align-items: center;
  transition: padding 0.3s ease, color 0.15s ease;
}
@media (hover: hover) {
  .button__half:hover > span {
    padding: 0 1vw;
    color: var(--green);
  }
}
.footer__icon {
  font-size: 1.05em;
}

.footer__meta {
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding-top: 1.2em;
  font: 400 12px/1.2 var(--mono);
  text-transform: uppercase;
  letter-spacing: -0.02em;
}
.footer__copy {
  opacity: 0.72; /* (AA on green at this size) */
}

@media (max-width: 699px) {
  .footer__base {
    grid-template-columns: auto minmax(0, 1fr);
    gap: 16px;
    padding-bottom: calc(20px + var(--safe-b));
  }
  .footer__barcode svg {
    width: 75px;
  }
  .footer__rows {
    font-size: min(14px, calc(5.71vw - 7.8px));
  }
  .footer__meta {
    font-size: 10px;
  }
  .footer__copy {
    white-space: nowrap;
  }
}

/* ======================================================================= 8. contact panel */

/* The overlays (the contact panel, the project sheet) are native <dialog>s shown modally: the top
   layer puts them above everything, and the browser makes the page behind inert. The UA's dialog
   box (margin, border, padding, size caps, canvas colours, its own scrolling) goes: each overlay
   lays itself out below. No scrim: the backdrop is clear, as the overlays always were. */
dialog.overlay {
  margin: 0;
  padding: 0;
  border: 0;
  width: auto;
  height: auto;
  max-width: none;
  max-height: none;
  background: transparent;
  overflow: visible;
}
dialog.overlay::backdrop {
  background: transparent;
}

.contact {
  position: fixed;
  inset: 0;
  color: var(--green-ink);
}
/* Once the wipe has covered the panel the dialog itself is green (contact.js, until the close
   starts): a real background for Safari to tint its bars from, and for the grain (section 11). */
.contact.is-covered {
  background-color: var(--green);
}
.contact__wipe {
  position: absolute;
  inset: 0;
  overflow: hidden;
}
/* The scroller starts under the header (which moves into the dialog and stays on top with
   "Close"), so nothing can scroll beneath the wordmark; the wipe layers still cover the whole
   screen. Its scrollbar (only when the form runs past the screen) is in the green's dark ink. */
.contact__scroll {
  --scroll-thumb: rgba(4, 33, 10, 0.4);
  position: absolute;
  inset: var(--hdr-h) 0 0;
  overflow-x: hidden;
  overflow-y: auto;
  overscroll-behavior: contain;
}
/* Desktop composition: the title holds the top-left, the pills and email the bottom-left, and
   the form the bottom-right, its Send button level with the email: the panel is filled
   corner to corner rather than stacked at the top. */
.contact__grid {
  min-height: 100%;
  display: grid;
  grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
  gap: 6vw;
  align-items: stretch;
  padding: clamp(40px, 6vw, 96px) var(--gutter) clamp(32px, 4vw, 64px);
}
.contact__intro {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
}
.contact__title {
  font: 600 clamp(48px, 8vw, 180px) / 0.9 var(--display);
  letter-spacing: -0.035em;
  margin: 0 0 0.4em -0.05em;
}
/* The title takes programmatic focus on touch screens (for screen readers): no ring. */
.contact__title:focus,
.contact__title:focus-visible {
  outline: none;
}

.quoteForm {
  align-self: end;
}
.quoteForm__form {
  position: relative;
  max-width: 620px;
}
/* Label, then the message beside it ("Email  Enter a valid email."), then the field. */
.quoteForm__field {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0 1em;
  margin-bottom: 22px;
}
/* (Dimmed labels at .72 of the ink still read 4.9:1 on the green: AA at this size.) */
.quoteForm__label {
  font: 400 min(13px, calc(3.6vw - 1px)) / 1.3 var(--mono);
  text-transform: uppercase;
  letter-spacing: 0.02em;
  opacity: 0.72;
}
.quoteForm__optional {
  text-transform: none;
  margin-left: 0.4em;
}
.quoteForm__error {
  font: 500 12px/1.3 var(--mono);
}
.quoteForm__field.is-invalid .quoteForm__input {
  border-bottom-color: var(--green-ink);
  box-shadow: 0 1px 0 0 var(--green-ink);
}
.quoteForm__field.is-invalid .quoteForm__label {
  opacity: 1;
}
.quoteForm__field.is-invalid .quoteForm__label::before {
  content: '*';
  font-weight: 700;
  margin-right: 0.25em;
}
.quoteForm__input {
  flex: 1 0 100%;
  width: 100%;
  margin-top: 4px;
  padding: 0.6em 0;
  border: 0;
  border-bottom: 1px solid var(--rule-green);
  border-radius: 0;
  background: transparent;
  color: var(--green-ink);
  font: 400 16px/1.4 var(--mono); /* >= 16px: no iOS zoom */
  letter-spacing: -0.01em;
  transition: border-color 0.25s ease, box-shadow 0.25s ease;
  -webkit-appearance: none;
  appearance: none;
}
/* Focus on an underline-only field: the rule thickens to a full-ink 3px line and the label
   comes up to full strength (a clear indicator without boxing the field in). The outline is
   there but transparent: forced-colors mode, which drops the box-shadow, paints it instead. */
.quoteForm .quoteForm__input:focus,
.quoteForm .quoteForm__input:focus-visible {
  outline: 2px solid transparent;
  border-bottom-color: var(--green-ink);
  box-shadow: 0 2px 0 0 var(--green-ink);
}
.quoteForm__field:focus-within .quoteForm__label {
  opacity: 1;
}
/* The field grows with what is typed (where field-sizing is supported) instead of carrying the
   UA's resize grabber: a corner glyph on an underline-only field, and on a cold browser its
   stroked-path shader cost a 120ms frame at the start of the panel's wipe. */
.quoteForm__input--area {
  resize: none;
  field-sizing: content;
  min-height: 6.4em;
  max-height: 50vh;
}
.quoteForm__input:-webkit-autofill {
  -webkit-text-fill-color: var(--green-ink);
  box-shadow: 0 0 0 100px var(--green) inset;
  transition: background-color 9999s;
}
.quoteForm .buttons {
  margin-top: 30px;
}
.button__pill {
  display: inline-flex;
  align-items: center;
  gap: 0.8em;
  min-height: 44px;
  padding: 0.8em 1.8em;
  border: 1px solid var(--green-ink);
  border-radius: 999px;
  background: var(--green-ink);
  color: var(--green);
  font: 400 13px/1 var(--mono);
  text-transform: uppercase;
  letter-spacing: -0.02em;
  transition: background-color 0.22s ease, color 0.22s ease;
}
@media (hover: hover) {
  .button__pill:hover {
    background: transparent;
    color: var(--green-ink);
  }
}
.quoteForm__form.is-busy .button__pill {
  opacity: 0.5;
  pointer-events: none;
}
.quoteForm__status {
  min-height: 1.4em;
  margin-top: 16px;
  font: 400 12px/1.4 var(--mono);
  text-transform: uppercase;
  letter-spacing: -0.02em;
}
/* Two columns: the status hangs under the Send button, so the button itself is what lines
   up with the email at the foot of the left column; the form keeps to the right edge, under
   the header's pill, when its column is wider than it. */
@media (min-width: 900px) {
  .quoteForm {
    justify-self: end;
    width: min(100%, 620px);
  }
  .quoteForm__status {
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
  }
}
/* Very large screens: the form and its small type grow with the title. */
@media (min-width: 1800px) {
  .quoteForm {
    width: min(100%, 780px);
  }
  .quoteForm__form {
    max-width: none;
  }
  .quoteForm__label {
    font-size: 15px;
  }
  .quoteForm__error {
    font-size: 14px;
  }
  .quoteForm__input {
    font-size: 19px;
  }
  .quoteForm .button__pill {
    font-size: 15px;
  }
}
/* Touch: an on-screen keyboard covers the lower half; room below the form lets the panel's
   scroller bring the field being typed in, and Send, up above it. A spacer after the grid rather
   than padding on it: the form keeps its geometry (a padded bottom-aligned grid slid the form up
   the panel on tablets).
   The spacer outlasts the field's focus by .6s (a delayed, zero-length height transition): a tap
   on Send blurs the field at touchstart / mousedown, and a spacer gone at once would shrink the
   scroll range, clamp the scroller and move Send out from under the finger before mouseup, so
   the click would land on the form and nothing would be sent. Refocusing a field brings it back
   at once (no delay that way). */
@media (pointer: coarse) {
  .contact__scroll::after {
    content: '';
    display: block;
    height: 0;
    transition: height 0s 0.6s;
  }
  .contact__scroll:has(.quoteForm__input:focus)::after {
    height: 45svh;
    transition-delay: 0s;
  }
}

@media (max-width: 899px) {
  .contact__grid {
    grid-template-columns: minmax(0, 1fr);
    align-content: start;
    align-items: start;
    gap: 40px;
    padding-top: 36px;
    padding-bottom: calc(40px + var(--safe-b));
  }
  .contact__title {
    font-size: clamp(48px, 14vw, 96px);
  }
  .quoteForm {
    align-self: start;
  }
}

/* ======================================================================= 9. SHEET
   The project sheet: dialog#sheet, shown modally (the top layer), a mist rounded panel. No scrim
   and no darkening around it: the margins show the page itself (the black #work behind). During a
   GL flight the panel is transparent (.is-flying) so the content reveals over the flying plane;
   DOM flights clip the panel from the source rect with a washing image copy (.sheet__wash). */

.sheet {
  --sheet-pad: 40px;
  --sheet-ink-soft: rgba(17, 17, 17, 0.7); /* (5.8:1 on the mist: AA for the small mono labels) */
  --sheet-rule: rgba(17, 17, 17, 0.16);
  --scroll-thumb: rgba(17, 17, 17, 0.28); /* (the scrollbars on the mist, section 2) */
  position: fixed;
  /* (clear of a notch / home indicator on a phone on its side: env() is 0 elsewhere) */
  inset: max(2.2svh, env(safe-area-inset-top)) max(3vw, env(safe-area-inset-right)) max(2.2svh, env(safe-area-inset-bottom))
    max(3vw, env(safe-area-inset-left));
  color: var(--ink);
  touch-action: pan-y pinch-zoom; /* horizontal swipes are prev / next */
}
/* Laid out but unseen while the content is built and measured. (Opacity, not visibility: the
   reduced-motion rule gives every property a .01ms transition, and a visibility that is still
   "hidden" for that instant would refuse the h1's focus.) */
.sheet.is-measuring {
  opacity: 0;
  pointer-events: none;
}
/* Closing over the GL plane: the sheet has handed over to the plane and is gone from view, while
   the dialog stays modal (and the page locked) until the plane is nearly home (sheet.js). */
.sheet.is-gone {
  visibility: hidden;
}
.sheet ::selection {
  background: var(--ink);
  color: var(--mist);
}
/* The header steps aside under the sheet (its wordmark and hairline would show in the sheet's
   margins and through the transparent panel over the flying plane); the contact panel opened from
   the sheet takes it into its own dialog and brings it back with "Close". */
body.sheet-open .hdr {
  opacity: 0;
  visibility: hidden;
  transition: color 0.2s ease, opacity 0.3s ease, visibility 0s linear 0.3s;
}
body.sheet-open.contact-open .hdr {
  opacity: 1;
  visibility: visible;
  transition: color 0.2s ease, opacity 0.3s ease;
}
body.sheet-open.contact-open.ink-snap .hdr {
  transition-duration: 0s, 0.3s; /* (see body.ink-snap .hdr) */
}
.sheet__panel {
  position: absolute;
  inset: 0;
  overflow: hidden;
  isolation: isolate;
  border-radius: var(--r-sheet);
  background: var(--mist);
}
.sheet.is-flying .sheet__panel {
  background: transparent;
}
.sheet__wash {
  position: absolute;
  z-index: 2;
  display: none;
  overflow: hidden;
  pointer-events: none;
}
.sheet.is-washing .sheet__wash {
  display: block;
}
.sheet__wash img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 0;
}

.sheet__body,
.sheet__media,
.sheet__copy {
  touch-action: pan-y pinch-zoom; /* (horizontal swipes are prev / next) */
}
/* The body is the sheet's one scroller, on every layout: a wheel or a swipe anywhere on the sheet
   scrolls the images, natively, and its thin scrollbar (or iOS's indicator) shows where you are.
   (A size container, so the copy column can be exactly one sheet tall: 100cqh.) */
.sheet__body {
  position: absolute;
  z-index: 1;
  inset: 0;
  display: grid;
  grid-template-columns: minmax(320px, 34%) minmax(0, 1fr);
  align-items: start;
  overflow-x: hidden;
  overflow-y: auto;
  overscroll-behavior: contain;
  container-type: size;
}

/* --- left column: copy (stays put, sticky, while the images scroll past beside it) --- */

/* One sheet tall. A copy column taller than that (a short window) scrolls itself; one that fits
   is no scroller at all, so a wheel over it scrolls the body.
   The height is the body's (100cqh); an engine without container units (Safari before 16, an
   iPad left on iPadOS 15) keeps the first declaration instead, the viewport less the sheet's
   top and bottom insets (section 9's .sheet rule), which is the same height, so the ring and
   prev / next stay pinned to the bottom there too.
   The -2px bottom margin lets the sticky box run just past the grid row without changing its
   size or the scroll range: the scroller rounds its scroll height up and its client height down,
   so at the very end of the scroll the row stops a fraction of a pixel short of the column's
   bottom, and sticky positioning would push the column up by it (a 1px jump of the copy). */
.sheet__copy {
  position: sticky;
  top: 0;
  height: calc(100svh - max(2.2svh, env(safe-area-inset-top)) - max(2.2svh, env(safe-area-inset-bottom)));
  height: 100cqh;
  margin-bottom: -2px;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: 32px;
  padding: var(--sheet-pad);
  overflow-y: auto;
}
.sheet__eyebrow {
  margin-bottom: clamp(18px, 2.4vh, 28px);
  font: 400 12px/1.2 var(--mono);
  text-transform: uppercase;
  letter-spacing: -0.02em;
  color: var(--sheet-ink-soft);
}
.sheet__title {
  margin: 0 0 clamp(16px, 2.2vh, 26px) -0.045em;
  font: 600 clamp(40px, 5.2vw, 88px) / 0.92 var(--display);
  letter-spacing: -0.035em;
  overflow-wrap: break-word;
  hyphens: manual;
}
/* The h1 takes programmatic focus on open (for screen readers): no ring around the title. */
.sheet__title:focus,
.sheet__title:focus-visible {
  outline: none;
}
/* Room for descenders inside the line masks at this tight line height. */
.sheet__title .line {
  padding: 0.04em 0 0.12em;
  margin: -0.04em 0 -0.12em;
}
.sheet__line {
  max-width: 36ch;
  font: 400 18px/1.35 var(--ui);
  letter-spacing: -0.012em;
}
.sheet__line .line {
  padding: 0.1em 0;
  margin: -0.1em 0;
}

.sheet__pills {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin-top: clamp(22px, 3.2vh, 34px);
}
.pill {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  display: inline-flex;
  align-items: center;
  gap: 0.6em;
  height: 32px;
  padding: 0 1.15em;
  border: 1px solid var(--ink);
  border-radius: 999px;
  color: var(--ink);
  font: 400 11px/1 var(--mono);
  text-transform: uppercase;
  letter-spacing: -0.02em;
  white-space: nowrap;
  transition: color 0.25s ease;
  transform-origin: 30% 50%;
}
.pill > :not(.flood) {
  position: relative;
}
.pill .flood {
  background: var(--ink);
}
.pill.is-flooded {
  color: var(--mist);
}
.pill .i {
  font-size: 13px;
}
.pill--visit {
  border-color: var(--green);
  background: var(--green);
  color: var(--green-ink);
}
.pill--visit .flood {
  background: var(--green-ink);
}
.pill--visit.is-flooded {
  color: var(--green);
}
.pill--visit:focus-visible {
  outline-color: var(--ink);
}

.sheet__notes {
  max-width: 40ch;
  margin-top: clamp(22px, 3.4vh, 36px);
  font: 400 11px/1.35 var(--mono);
  text-transform: uppercase;
  letter-spacing: -0.02em;
}
.sheet__notes li {
  padding: 10px 0 9px;
  border-top: 1px solid var(--sheet-rule);
}
.sheet__notes li:last-child {
  border-bottom: 1px solid var(--sheet-rule);
}
.sheet__start {
  display: inline-flex;
  align-items: center;
  gap: 0.7em;
  margin-top: clamp(18px, 2.6vh, 26px);
  padding: 0.35em 0;
  border-bottom: 1px solid currentColor;
  font: 400 12px/1.2 var(--mono);
  text-transform: uppercase;
  letter-spacing: -0.02em;
}
.sheet__start .i {
  transition: transform 0.6s var(--expo-out);
}
@media (hover: hover) {
  .sheet__start:hover .i {
    transform: translateX(4px);
  }
}

.sheet__foot {
  display: flex;
  align-items: center;
  gap: 18px;
  font: 400 12px/1 var(--mono);
  text-transform: uppercase;
  letter-spacing: -0.02em;
}
.sheet__ring {
  width: 28px;
  height: 28px;
  flex: none;
  transform: rotate(-90deg);
  overflow: visible;
}
.sheet__ring circle {
  fill: none;
  stroke: var(--ink);
  stroke-width: 2;
}
.sheet__ring-track {
  opacity: 0.16;
}
.sheet__nav {
  display: flex;
  align-items: center;
  gap: 0.75em;
}
.sheet__nav button {
  display: inline-flex;
  align-items: center;
  gap: 0.6em;
  padding: 0.5em 0;
  border-radius: 3px;
  text-transform: inherit;
  letter-spacing: inherit;
  transition: opacity 0.3s ease;
}
.sheet__nav .i {
  transition: transform 0.6s var(--expo-out);
}
@media (hover: hover) {
  .sheet__nav button:hover .i {
    transform: translateX(3px);
  }
  .sheet__prev:hover .i {
    transform: translateX(-3px) !important;
  }
}
.sheet__slash {
  opacity: 0.4;
}

/* --- right column: the image stack, scrolled by the body --- */

.sheet__media {
  position: relative;
  padding: var(--sheet-pad) 104px var(--sheet-pad) 8px;
}
/* Keyboard focus: the sheet's ink ring (section 11, forced-colors safe) with an inset line inside it. */
.sheet__media:focus-visible {
  box-shadow: inset 0 0 0 2px var(--ink);
  border-radius: 12px;
}
.sheet__stack {
  display: flex;
  flex-direction: column;
  gap: clamp(24px, 4vw, 64px);
}
.sheet__frame,
.sheet__phone {
  position: relative;
  overflow: hidden;
  border: 1px solid rgba(17, 17, 17, 0.12);
  background: #c8cbcf;
}
.sheet__frame {
  aspect-ratio: 16 / 10;
  border-radius: 10px;
}
.sheet__frame img,
.sheet__phone img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 0;
  opacity: 0;
  transition: opacity 0.6s ease;
  -webkit-user-drag: none;
  user-select: none;
}
.sheet__frame.is-loaded img,
.sheet__phone.is-loaded img {
  opacity: 1;
}
/* Loading: a soft light sweep across the empty frame (per-frame; stops once the image is in). */
.sheet__frame:not(.is-loaded)::after,
.sheet__phone:not(.is-loaded)::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(100deg, rgba(255, 255, 255, 0) 35%, rgba(255, 255, 255, 0.32) 50%, rgba(255, 255, 255, 0) 65%) 0 0 / 250% 100% no-repeat;
  animation: sheet-sheen 1.6s ease-in-out infinite;
}
@keyframes sheet-sheen {
  from {
    background-position: 130% 0;
  }
  to {
    background-position: -30% 0;
  }
}
@media (prefers-reduced-motion: reduce) {
  .sheet__frame::after,
  .sheet__phone::after {
    animation: none;
  }
}
.sheet__phones {
  display: flex;
  justify-content: center;
  gap: clamp(16px, 2.2vw, 36px);
}
.sheet__phone {
  flex: 1 1 0;
  max-width: 260px;
  aspect-ratio: 540 / 1169;
  border-radius: 22px;
}
.sheet__nextrow {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 14px;
  width: 100%;
  padding: 26px 0 8px;
  border-top: 1px solid rgba(17, 17, 17, 0.2);
  text-align: left;
  color: var(--ink);
}
.sheet__nextrow-k {
  font: 400 12px/1 var(--mono);
  text-transform: uppercase;
  letter-spacing: -0.02em;
  color: var(--sheet-ink-soft);
}
.sheet__nextrow-name {
  display: inline-flex;
  align-items: center;
  gap: 0.35em;
  font: 600 40px/0.95 var(--display);
  letter-spacing: -0.03em;
}
.sheet__nextrow-name .i {
  font-size: 0.7em;
  transition: transform 0.7s var(--expo-out);
}
@media (hover: hover) {
  .sheet__nextrow:hover .sheet__nextrow-name .i {
    transform: translateX(8px);
  }
}

/* --- close: a round black button whose fill hollows to a ring on hover, the X turning 90° --- */

.sheet__close {
  position: absolute;
  z-index: 3;
  top: 24px;
  right: 24px;
  width: 48px;
  height: 48px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  box-shadow: inset 0 0 0 1.5px var(--ink);
  color: var(--mist);
  font-size: 16px;
  transition: color 0.35s ease;
}
.sheet__close::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  background: var(--ink);
  transition: transform 0.6s var(--expo-out);
}
.sheet__close .i {
  position: relative;
  transition: transform 0.7s var(--expo-out);
}
@media (hover: hover) {
  .sheet__close:hover {
    color: var(--ink);
  }
  .sheet__close:hover::before {
    transform: scale(0);
  }
  .sheet__close:hover .i {
    transform: rotate(90deg);
  }
}
.sheet__close:active .i {
  transform: rotate(90deg) scale(0.9);
}
.sheet :focus-visible {
  outline-color: var(--ink);
}

/* Mid widths: a narrower copy column and smaller gutters so long names still fit. */
@media (max-width: 1180px) and (min-width: 700px) {
  .sheet {
    --sheet-pad: 32px;
  }
  .sheet__body {
    grid-template-columns: minmax(300px, 36%) minmax(0, 1fr);
  }
  .sheet__title {
    font-size: clamp(36px, 4.8vw, 88px);
  }
  .sheet__media {
    padding-right: 92px;
  }
}

/* Very large screens: the copy column scales with the sheet instead of leaving its upper
   half of type marooned at the top of a tall, wide column. */
@media (min-width: 1800px) {
  .sheet__body {
    grid-template-columns: minmax(320px, 30%) minmax(0, 1fr);
  }
  .sheet__title {
    font-size: clamp(88px, 5.6vw, 150px);
  }
  .sheet__line {
    font-size: 22px;
  }
}

/* Compact: one column, the whole sheet scrolls, close fixed at the bottom-right. */
@media (max-width: 699px) {
  .sheet {
    inset: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom))
      max(12px, env(safe-area-inset-left));
  }
  /* The work comes first on a phone: the notes stay a desktop detail. */
  .sheet__notes {
    display: none;
  }
  .sheet__body {
    display: block;
    /* none, not contain: no bounce at the top, so a drag down there moves only the sheet
       (sheet.js pulls it down to close) and never the content inside it as well. */
    overscroll-behavior: none;
  }
  .sheet__copy {
    position: static;
    height: auto;
    margin-bottom: 0; /* (no sticky column here to let past its row) */
    overflow: visible;
    gap: 28px;
    padding: 30px 22px 8px;
  }
  .sheet__title {
    font-size: clamp(34px, 10vw, 48px);
  }
  .sheet__line {
    font-size: 16px;
  }
  .sheet__ring {
    display: none;
  }
  .sheet__media {
    padding: 28px 12px 100px;
  }
  .sheet__stack {
    gap: 24px;
  }
  .sheet__frame {
    border-radius: 8px;
  }
  .sheet__phones {
    flex-wrap: wrap;
    justify-content: space-between;
    row-gap: 14px; /* (a % row gap resolves against an indefinite height: 0) */
    column-gap: 0;
  }
  .sheet__phones--3 {
    justify-content: center;
    column-gap: 4%;
  }
  .sheet__phone {
    flex: 0 0 48%;
    max-width: none;
    border-radius: 18px;
  }
  .sheet__nextrow {
    padding-top: 22px;
  }
  .sheet__nextrow-name {
    font-size: 30px;
  }
  .sheet__close {
    top: auto;
    bottom: 14px;
    right: 14px;
    width: 52px;
    height: 52px;
  }
}

/* ======================================================================= 10. INDEX VIEW
   nav#work-index inside #work (z 2: above the canvas, below the wipe and HUD). Names centred and
   wrapped with superscript indices and green dots in the gaps; a cursor-following preview card
   sits behind them on desktop (indexview.js drives its transform). */

.work-index {
  --ix-gap: clamp(30px, 3.4vw, 60px);
  position: absolute;
  z-index: 2;
  inset: 0;
  display: grid;
  place-items: center;
  padding: calc(var(--hdr-h) + 64px) var(--gutter) 128px;
  pointer-events: none;
}
.work-index__list {
  position: relative;
  z-index: 1;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: baseline;
  column-gap: var(--ix-gap);
  row-gap: 0.12em;
  max-width: min(1240px, 100%);
  margin-top: -6vh; /* sit a little above centre: the floor and its reflections own the lower half */
  font: 600 clamp(28px, 4.4vw, 68px) / 1.06 var(--display);
  letter-spacing: -0.03em;
  color: var(--mist);
  text-align: center;
}
.work-index__item {
  position: relative;
  white-space: nowrap;
}
.work-index__link {
  display: inline-block;
  border-radius: 6px;
  pointer-events: auto;
  transition: opacity 0.35s ease, color 0.35s ease;
}
.work-index__mask {
  display: inline-block;
  overflow: hidden;
  vertical-align: top;
  padding: 0.06em 0.05em 0.12em;
  margin: -0.06em -0.05em -0.12em;
}
.work-index__inner {
  display: inline-block;
}
.work-index__dot {
  position: absolute;
  left: 100%;
  top: 54%;
  width: 7px;
  height: 7px;
  margin-left: calc(var(--ix-gap) / 2);
  border-radius: 50%;
  background: var(--green);
  translate: -50% -50%;
  pointer-events: none;
}
.work-index__item.no-dot .work-index__dot {
  display: none;
}
.work-index__list.has-hover .work-index__link {
  opacity: 0.35;
}
.work-index__list.has-hover .work-index__link.is-active {
  opacity: 1;
  color: #fff;
  text-shadow: 0 0 24px rgba(0, 0, 0, 0.45); /* (a soft lift off the preview card behind it) */
}
.work-index__dot {
  transition: opacity 0.35s ease;
}
.work-index__list.has-hover .work-index__dot {
  opacity: 0.35;
}
.js .work-index__inner {
  transform: translateY(135%); /* hidden until show() reveals it (clear of the mask padding) */
}

.work-index__preview {
  position: fixed;
  z-index: 0;
  left: 0;
  top: 0;
  width: 320px;
  height: 200px;
  visibility: hidden;
  opacity: 0;
  pointer-events: none;
  will-change: transform, opacity;
}
.work-index__card {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
  border-radius: 14px;
  background: #111;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.14);
}
.work-index__card img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 0;
  opacity: 0;
  filter: brightness(0.55) saturate(0.9); /* per-surface: keeps the white name readable over a bright cover */
  transition: opacity 0.35s ease;
}
.work-index__card img.is-on {
  opacity: 1;
}

/* No WebGL: the fallback row steps away while the Index shows, and while a sheet is up (its
   neighbours would otherwise peek into the sheet's margins). */
.no-gl .work-list {
  transition: opacity 0.5s ease, visibility 0s linear 0s;
}
.no-gl body.sheet-cover .work-list {
  opacity: 0;
  transition: opacity 0.35s ease;
}
.no-gl .work[data-mode='index'] .work-list {
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.4s ease, visibility 0s linear 0.4s;
}

@media (max-width: 699px) {
  .work-index {
    --ix-gap: 26px;
    padding: calc(var(--hdr-h) + 56px) 20px 110px;
  }
  .work-index__list {
    row-gap: 0.2em;
    margin-top: -4vh;
  }
  .work-index__dot {
    width: 6px;
    height: 6px;
  }
}

/* ======================================================================= short landscape
   A phone on its side (wider than "compact", but under 500px tall): a slimmer header, the
   sections exactly one screen each, and cards sized to the height, so the ribbon, its floor
   and the HUD row all fit on screen with #work framed. */

@media (orientation: landscape) and (max-height: 500px) {
  :root {
    --hdr-h: 52px;
    --slot-h: 26px;
  }
  .hero,
  .work {
    min-height: 0;
  }
  .work {
    --card-h: min(50svh, 300px);
  }
  .hero__foot {
    bottom: calc(14px + var(--safe-b));
  }
  .work-hud__eyebrow {
    top: calc(var(--hdr-h) + 10px);
  }
  .work-hud__bar {
    bottom: max(12px, var(--safe-b));
  }
  @supports (height: 100dvh) {
    .work-hud__bar {
      bottom: calc(100lvh - 100dvh + max(12px, var(--safe-b)));
    }
  }
  .work-index {
    padding-top: calc(var(--hdr-h) + 36px);
    padding-bottom: 64px;
  }
  .contact__title {
    font-size: clamp(40px, 13vh, 72px);
  }
  .footer__stack {
    --stack-top: calc(var(--hdr-h) + 28px); /* (the footer fits the screen exactly too) */
  }
  /* (Every section is one screen here, so the page snaps onto each like a tall phone's.) */
}

/* ======================================================================= 11. grain, focus, motion */

/* Film grain: a uniform overlay that jitters in steps. Not an edge effect: same everywhere.
   The overlays are dialogs in the top layer, above the page's .grain, so each carries the same
   grain itself: the contact panel once its wipe has covered it (blended over the dialog's own
   green, as the page's grain is over the footer's), the sheet inside its rounded panel. While the
   sheet's panel is transparent over a flying GL plane, the page's own grain shows through it. */
.grain,
.contact.is-covered::after,
.sheet__panel::after {
  top: -10rem;
  left: -10rem;
  width: calc(100% + 20rem);
  height: calc(100% + 20rem);
  background: url('../img/noise.png') 50% / 100px 100px repeat;
  mix-blend-mode: overlay;
  opacity: 0.22;
  pointer-events: none;
  animation: grain 0.5s steps(1) infinite;
}
.grain,
.contact.is-covered::after {
  position: fixed;
  z-index: 100;
}
.contact.is-covered::after,
.sheet__panel::after {
  content: '';
}
.sheet__panel::after {
  position: absolute;
  z-index: 4;
}
.sheet.is-flying .sheet__panel::after {
  display: none;
}
@keyframes grain {
  0% { transform: translate3d(0, 9rem, 0); }
  10% { transform: translate3d(-1rem, -4rem, 0); }
  20% { transform: translate3d(-8rem, 2rem, 0); }
  30% { transform: translate3d(9rem, -9rem, 0); }
  40% { transform: translate3d(-2rem, 7rem, 0); }
  50% { transform: translate3d(-9rem, -4rem, 0); }
  60% { transform: translate3d(2rem, 6rem, 0); }
  70% { transform: translate3d(7rem, -8rem, 0); }
  80% { transform: translate3d(-9rem, 1rem, 0); }
  90% { transform: translate3d(6rem, -5rem, 0); }
  100% { transform: translate3d(-7rem, 0, 0); }
}
@media (max-width: 699px), (hover: none) {
  .grain,
  .contact.is-covered::after,
  .sheet__panel::after {
    display: none; /* desktop only */
  }
}

/* Keyboard focus: a 2px ring in the surface's ink (the browser's :focus-visible decides when, so
   a click or tap shows none). Where a control draws its own indicator instead (the form's
   underline), its outline stays but turns transparent, so forced-colors mode still paints one. */
:focus-visible {
  outline: 2px solid var(--green);
  outline-offset: 3px;
}
.footer :focus-visible,
.contact :focus-visible,
body.ink-dark .hdr :focus-visible {
  outline-color: var(--green-ink);
}
.work-card:focus-visible {
  outline-offset: 4px;
}
/* Sections receive programmatic focus after a skip / in-page jump: no ring around a whole screen. */
main > section[tabindex='-1']:focus {
  outline: none;
}
/* Nor around a whole overlay: Safari makes a modal <dialog> itself focusable (a Tab wrapping back
   in from the browser's UI stops on it, and a click on plain text in it focuses it), and the
   square ring would frame the rounded sheet. The controls inside keep theirs. */
dialog.overlay:focus-visible {
  outline: none;
}

/* Reduced motion (live, from the media query): the grain holds still, the sheet's loading sheen
   (section 9) stops, and transitions finish at once. (Scripted scrolls already jump: smooth
   scrolling is only switched on for no-preference, section 2.) */
@media (prefers-reduced-motion: reduce) {
  .grain,
  .contact.is-covered::after,
  .sheet__panel::after {
    animation: none;
  }
  *,
  *::before,
  *::after {
    transition-duration: 0.01ms !important;
  }
}
