/* ---------- fonts ---------- */
@font-face {
  font-family: "PP Editorial Old";
  src: url("assets/fonts/PPEditorialOld-Regular.woff2") format("woff2");
  font-weight: 400; font-style: normal; font-display: block;
}
@font-face {
  font-family: "PP Editorial Old";
  src: url("assets/fonts/PPEditorialOld-Italic.woff2") format("woff2");
  font-weight: 400; font-style: italic; font-display: block;
}
@font-face {
  font-family: "Archivo";
  src: url("assets/fonts/Archivo-Variable.woff2") format("woff2");
  font-weight: 100 900; font-style: normal; font-display: swap;
}
@font-face {
  font-family: "Archivo";
  src: url("assets/fonts/Archivo-VariableItalic.woff2") format("woff2");
  font-weight: 100 900; font-style: italic; font-display: swap;
}

/* ---------- tokens ---------- */
:root {
  /* design token; the decoded video reads rgb(219,207,202), 1 level off in G/B.
     The 6px feather on .hero__media absorbs the difference. */
  --bg: #E0D6D1;
  --ink: #000;
  --muted: #747474;        /* nav, inactive */
  --lime: #CEF52C;         /* scroll cue, tag outlines */
  --card: #F4F4F3;         /* work card field */
  --link: #0117C3;         /* the one link in the footer */
  --muted-ink: #333;       /* footer body copy */

  /* hero geometry, taken from the Figma frame (1512 x 982).
     Declared in vh first and upgraded to svh inside @supports below: if the
     small-viewport units are missing, `top: var(--title-top)` would be
     invalid at computed-value time and collapse to `top: auto`, dropping
     every absolutely-placed piece onto its static position at the top of
     the hero -- the duck landing on the headline and the headline sliding
     out from under its own left offset. vh degrades to something sane. */
  /* Centre, not left edge: once the width is height-capped (below) a
     left-anchored box drifts off to one side on a wide window. 47.95% is
     where the artboard's 236..1214 box centres in its 1512 frame. */
  --media-left: 47.95%;
  --media-top: 15.58vh;    /* 153 / 982 */
  --media-width: 64.68%;
  /* Deliberately generous. With a hard <br> doing the break, the container
     has exactly one job: never be the thing that wraps line 1. The artboard's
     70.57% (plus an 8.9em cap on .headline) measured 8.389em against a 8.9em
     limit in Chromium -- half an em of headroom -- and Safari still wrapped
     "function" onto its own line. Not the ligatures: dlig on vs off changes
     line 1 by 0.001em, measured. Whatever Safari is doing, 90% gives line 1
     more than 40% of slack and the question cannot come apart again. */
  --title-left: 5%;
  --title-top: calc(21.69vh + 80px);   /* artboard 213, pushed down 80 */
  --title-width: 90%;

  /* The answer starts a full viewport below the fold. This is the whole
     point of the sticky hero: screen one is the question and the duck, and
     nothing else -- the answer cannot be glimpsed before it is scrolled to. */
  --answer-left: 17.06%;   /* 258 / 1512 */
  --answer-top: 108vh;
  --answer-width: 67.66%;  /* 1023 / 1512 */

  /* One screen of scrolling drives the whole hero move. */
  --scrub: 100vh;

  /* Reference aspect: 978 x 734 of a 1512 x 982 frame, i.e. the duck is
     74.75% of the frame height. See --media-cap below. */
  --media-cap: 99.6vh;
}

/* svh/lvh where they exist (Chrome 108+, Safari 15.4+, Firefox 101+). The
   vh values above are the fallback, and vh == lvh, so nothing lands in a
   worse place -- the answer still parks below the tallest viewport. */
@supports (height: 1svh) {
  :root {
    --media-top: 15.58svh;
    --title-top: calc(21.69svh + 80px);
    /* 100lvh, not 100svh: on mobile the visible viewport grows to lvh when
       the URL bar retracts, and an svh-based park would slide into view.
       lvh is the tallest the viewport can get, so +8svh is below the fold
       unconditionally. On desktop lvh == svh and this is just 108svh. */
    --answer-top: calc(100lvh + 8svh);
    --scrub: 100svh;
    --media-cap: 99.6svh;
  }
}

/* ---------- base ---------- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: "Archivo", system-ui, sans-serif;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

.sr-only {
  position: absolute; 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;
}

/* ---------- nav (parked -- markup commented out in index.html) ---------- */
/* Absolute, not fixed: the artboard shows it once, at the top of the page.
   No positioned ancestor, so it resolves against the initial containing
   block -- i.e. the document origin -- and scrolls away with the hero. */
.nav {
  position: absolute;
  inset: 66px 0 auto 0;      /* artboard y=66 */
  z-index: 5;
}
.nav__inner {
  display: flex;
  justify-content: flex-end;
  /* artboard glyph runs start at 925 / 1042 / 1164 / 1310, i.e. gaps of
     83 / 90 / 82 once word widths are taken out. Evened to 85. */
  gap: 85px;
  padding-right: 9.66%;      /* 146 / 1512 -- lands 'About me' at x=1366 */
  font-family: "PP Editorial Old", Georgia, serif;
  font-size: 14px;
  line-height: 1.1;
  font-variant-ligatures: common-ligatures discretionary-ligatures;
  -webkit-font-feature-settings: "liga" 1, "dlig" 1;
          font-feature-settings: "liga" 1, "dlig" 1;
}
.nav__link {
  color: var(--muted);
  text-decoration: none;
  transition: color 220ms ease;
}
.nav__link:hover,
.nav__link:focus-visible { color: var(--ink); }
.nav__link.is-current {
  color: var(--ink);
  text-decoration: underline;
  text-decoration-thickness: from-font;
  text-underline-position: from-font;
}

/* ---------- hero ---------- */
/* The track is taller than the viewport by exactly --scrub; .hero sticks to
   the top for that distance and JS maps the scrolled fraction onto three
   transforms. Everything inside the hero is absolutely placed, because the
   three pieces move by different amounts and flow can't express that. */
.hero-track {
  position: relative;
  height: calc(100vh + var(--scrub));       /* fallback -- see the note at :root */
  height: calc(100svh + var(--scrub));
}

.hero {
  position: sticky;
  top: 0;
  height: 100vh;                            /* fallback */
  height: 100svh;
  z-index: 1;
  /* deliberately NOT clipped: the answer parks a viewport below the hero
     box before it is scrolled up into it, and clipping would eat it. */
}

.hero__media {
  position: absolute;
  left: var(--media-left);
  translate: -50% 0;                       /* composes with the JS transform */
  top: var(--media-top);
  /* ⚠️ Sized by BOTH axes. Width alone (64.68vw) is only correct at the
     artboard's 1512x982; on any shorter or wider window the duck keeps
     growing while everything around it -- placed in svh -- compresses, and
     it swallows the headline. Verified: at 1300x743 the width-only box was
     631px tall (85svh vs the reference 75svh) and sat on line 2; at
     2560x1300 it was 1243px tall in a 1300px viewport.
     The cap is the reference height, 74.75svh, expressed as a width via the
     978/734 aspect -- so past the reference aspect ratio the duck is height-
     driven and the composition is identical at every window shape. */
  width: min(var(--media-width), var(--media-cap));
  aspect-ratio: 978 / 734;
  z-index: 1;
  will-change: transform;

  /* No mask needed: the video has a real alpha channel, so there is no
     rectangular edge to hide -- and a feather would eat the duck whenever
     it drifts near the frame boundary. */
}

/* The WebGL canvas that replaced .hero__video. Same job: fill the media box.
   The canvas is transparent, so --bg shows through it -- the alpha-video win,
   with no keying and a measured seam delta of zero, because there is no seam.

   pointer-events: none so the duck never eats a click; it watches the cursor
   but it is not a control. */
.hero__stage {
  width: 100%; height: 100%;
  display: block;
  pointer-events: none;
}
.hero__stage canvas {
  display: block;
  width: 100%; height: 100%;
}

/* Kept: restoring the video is putting the <video class="hero__video"> back
   inside .hero__media and deleting the module script in index.html. */
.hero__video {
  width: 100%; height: 100%;
  object-fit: cover;
  display: block;
}

.hero__title {
  position: absolute;
  left: var(--title-left);           /* artboard x=236, not centred */
  top: var(--title-top);
  width: var(--title-width);
  margin: 0;
  z-index: 2;
  text-align: center;
  will-change: transform;
  font-family: "PP Editorial Old", Georgia, serif;
  font-weight: 400;
  /* 114px at the 1512px design width; scales fluidly, capped so it
     doesn't run away on very wide displays */
  font-size: clamp(2.25rem, 7.54vw, 8.5rem);
  line-height: 1.16;
  letter-spacing: -0.005em;
  /* PP Editorial Old's custom ligatures live in dlig.
     font-variant-ligatures is the correct high-level property, but Safari
     does not reliably honour `discretionary-ligatures` through it, so the
     low-level property is declared alongside as a fallback. Safe here
     because it is scoped to this rule and lists every feature it needs --
     the clobbering hazard only bites when descendants redeclare it. */
  font-variant-ligatures: common-ligatures discretionary-ligatures;
  -webkit-font-feature-settings: "liga" 1, "dlig" 1;
          font-feature-settings: "liga" 1, "dlig" 1;
}

/* The break after "function" is a hard <br class="hb">.
   A natural wrap held by max-width: 8.9em gave two lines in Chromium at
   every width from 480 to 2560, but Safari on a 14" Mac took it to three
   and the cause was never reproducible here (dlig on/off moves line 1 by
   0.01%, so it was not the ligatures). A hard break cannot disagree between
   engines. The 8.9em max-width that used to back this up has since been
   removed -- with the hard break doing the work it never bound anyway
   (line 1 measured 8.389em against it). --title-width: 90% is now the only
   thing keeping line 1 off the edge, and it has ~40% of slack.
   The swap only ever rewrites line 2, so line 1 cannot reflow mid-typing,
   which was the part that actually read as broken. */
.headline {
  display: block;
  opacity: 0;
  transform: translateY(0.14em);
  filter: blur(6px);
}
.headline.is-in {
  opacity: 1;
  transform: none;
  filter: none;
  transition:
    opacity 900ms cubic-bezier(.16,1,.3,1),
    transform 1100ms cubic-bezier(.16,1,.3,1),
    filter 900ms cubic-bezier(.16,1,.3,1);
}

/* keep the swapped phrase from breaking across lines mid-animation */
.swap { white-space: nowrap; }

/* typing caret */
.caret {
  display: inline-block;
  width: 0.05em;
  height: 0.74em;
  margin-left: 0.03em;
  background: currentColor;
  vertical-align: -0.06em;
  opacity: 0;
}
.caret.is-active { animation: blink 1.06s steps(1, end) infinite; }
@keyframes blink { 0%, 50% { opacity: 1; } 50.01%, 100% { opacity: 0; } }

/* ---------- definition ---------- */
/* answer: 64px off the artboard, leading 1.1. Parked below the fold and
   walked up by initHero(). */
.hero__answer {
  position: absolute;
  left: var(--answer-left);
  top: var(--answer-top);
  width: var(--answer-width);
  margin: 0;
  z-index: 2;
  text-align: center;
  will-change: transform;
  font-family: "PP Editorial Old", Georgia, serif;
  font-weight: 400;
  font-size: clamp(1.75rem, 4.233vw, 4.8rem);
  line-height: 1.1;
  letter-spacing: -0.005em;
  font-variant-ligatures: common-ligatures discretionary-ligatures;
  -webkit-font-feature-settings: "liga" 1, "dlig" 1;
          font-feature-settings: "liga" 1, "dlig" 1;
}

.subhead {
  position: relative;
  z-index: 2;
  /* No parallax. It lagged at 0.12, which meant the explainer drifted DOWN
     the further you scrolled -- reading as the paragraph sliding away from
     you, plus a visible jump when the cached base position was re-measured.
     It only ever fought the reading. The fade-up on enter stays.
     With the drift gone the bottom padding is just the artboard's gap to
     the scroll cue, no pre-payment needed. */
  padding: 0 0 60px;
}
.subhead__inner {
  /* artboard: 661px wide at x=439 */
  width: 43.72%;
  margin-left: 29.03%;
  text-align: center;
  font-size: 20px;
  line-height: 1.1;
  color: #333;
  opacity: 0;
  transform: translateY(28px);
}
.subhead__inner p { margin: 0 0 18px; }
.subhead__inner p:last-child { margin-bottom: 0; }
.subhead__note { font-weight: 250; font-style: italic; opacity: .6; }

.subhead__inner.is-in {
  opacity: 1;
  transform: none;
  transition: opacity 900ms cubic-bezier(.16,1,.3,1), transform 1000ms cubic-bezier(.16,1,.3,1);
}

/* ---------- scroll cue ---------- */
.cue {
  position: relative;
  z-index: 2;
  display: block;
  /* artboard 1 (stack): cue ends 1228, first card starts 1380 -> 152.
     artboard 2 (grid):  first category head starts 1340 -> 112, set
     inline on the cue in index.html. */
  margin: 0 0 var(--cue-gap, 152px);
  text-align: center;
  text-decoration: none;
  color: var(--lime);
  font-family: "PP Editorial Old", Georgia, serif;
  font-size: 100px;
  line-height: 0.98;         /* artboard: 98px box on 100px type */
}
.cue__glyph {
  display: inline-block;
  animation: bob 2.8s cubic-bezier(.45,0,.55,1) infinite;
}
@keyframes bob {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(12px); }
}

/* ---------- work ---------- */
/* NORMAL FLOW, deliberately -- not grid.
   A grid item's containing block is its own grid area, which is exactly its
   own size, so a sticky grid child has nowhere to stick and silently does
   nothing. In flow the containing block is .work's content box and each
   .reveal can hold still for as long as the section lasts. The gap that used
   to come from `gap` is now a margin. */
.work {
  position: relative;
  z-index: 2;
  padding: 0 3.17% 14vh;     /* artboard: card inset 48px each side */
}
.work > * + * { margin-top: var(--work-gap, 117px); }  /* artboard: 2099 -> 2216 */

/* ---------- the reveal: pin, then hand off ----------
   Each block holds still at --pin while the next one rises over it and takes
   the screen. --z is paint order, so later blocks cover earlier ones; the
   covered one recedes and fades rather than sitting there behind the gaps,
   which is what makes it work for the half-width blocks that cannot cover
   anything.

   The sticky box is never transformed -- .reveal__in carries the motion.
   Transforming the sticky element itself would move the rect the script
   measures, and the reveal would drive itself. --pin is written by the
   script, because it depends on the block's height against the viewport.

     --in   0 -> 1 as the block rises into place
     --out  0 -> 1 as the NEXT block rises into place  (script: out[i] = in[i+1])
*/
.reveal {
  position: sticky;
  top: var(--pin, 6vh);
  z-index: var(--z, 1);
  width: 100%;
}
.reveal__in {
  transform:
    translate3d(0, calc((1 - var(--in, 1)) * var(--rise, 44px)), 0)
    scale(calc(1 - var(--out, 0) * var(--out-scale, 0.05)));
  transform-origin: 50% 25%;
  opacity: calc(var(--in, 1) * (1 - var(--out, 0) * var(--out-fade, 1)));
}

@media (prefers-reduced-motion: reduce) {
  /* pinning is motion the visitor did not ask for; give them the plain page */
  .reveal { position: static; }
  .reveal__in { transform: none; opacity: 1; }
}

/* Absolutely positioned inside a fixed-aspect box, so the card reproduces
   the artboard exactly at any width. Collapses to flow below 860px. */
.card {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 1393 / 719;
  background: var(--card);
  color: var(--ink);
  text-decoration: none;
  overflow: hidden;
}

/* Left edge and width come off the artboard; the height follows the export's
   own aspect (2200x1492, cropped to the alpha bbox) and is centred, so the
   spread can never be letterboxed or squashed by a card-aspect mismatch. */
.card__media {
  position: absolute;
  left: 2.73%;
  top: 50%;
  transform: translateY(-50%);
  width: 64.46%;
  aspect-ratio: 2200 / 1492;
}
.card__img {
  position: absolute;
  inset: 0;
  width: 100%; height: 100%;
  /* contain, not cover: the spread is artwork with its own edges and must
     not be cropped when the card's aspect and the export's disagree */
  object-fit: contain;
  object-position: center;
  display: block;
}
.card__img--hover {
  opacity: 0;
  transition: opacity 420ms cubic-bezier(.16,1,.3,1);
}
.card__media:hover .card__img--hover,
.card:focus-visible .card__img--hover { opacity: 1; }
.card:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; }

.card__title {
  position: absolute;
  left: 69.63%; top: 11.82%;   /* artboard x=1018, y=1569 */
  width: 24.5%;
  margin: 0;
  font-family: "Archivo", system-ui, sans-serif;
  font-weight: 500;
  font-size: 22px;
  line-height: 1.1;
}

/* Marker highlight on card hover. The gradient is on an inline span, not the
   h2, so the stripe hugs the text instead of the 24.5% column -- and
   box-decoration-break: clone keeps it correct if the title ever wraps.
   Negative margin cancels the padding so the overshoot doesn't move the
   text off its artboard position. */
.card__title span {
  background-image: linear-gradient(var(--lime), var(--lime));
  background-repeat: no-repeat;
  background-position: 0 0.08em;
  background-size: 0% 1.02em;
  padding: 0 0.16em;
  margin: 0 -0.16em;
  -webkit-box-decoration-break: clone;
          box-decoration-break: clone;
  transition: background-size 420ms cubic-bezier(.16,1,.3,1);
}
.card__title span:hover,
.card:focus-visible .card__title span { background-size: 100% 1.02em; }

.card__tags {
  position: absolute;
  right: 5.60%; bottom: 8.34%;
  display: flex;
  flex-direction: column;
  align-items: flex-end;       /* artboard staggers these; right-aligned reads cleaner */
  gap: 8px;
  margin: 0; padding: 0;
  list-style: none;
}
.card__tags li {
  border: 1px solid var(--lime);
  border-radius: 999px;
  padding: 0.8em 1.4em;
  font-size: 12px;
  line-height: 1;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

/* ---------- poster + lottie overlay ---------- */
.poster {
  position: relative;
  width: 47.88%;             /* artboard 678 wide, inside a 1416 column */
  aspect-ratio: 2000 / 2157; /* the square plate's own ratio -> 678 x 731 */
}
.poster__bg {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  display: block;
}

/* The SVG viewBox is the artwork's UNION bbox across the whole loop
   (110 247 1653 572), not the 1920x1080 comp -- 54% of which is empty.
   So the stage box is the union box, and the wordmark sits inside it.

   Placed off FRAME 1 (bbox 396.88 253.61 1125.58 565.21 in comp units),
   centred in the plate's SOLID RED field -- the area below the photograph,
   inside the printed sheet, clear of the tape tabs. Measured on the plate:
   x 50..1958, y 1131..2053 of 2000 x 2157, so its centre is 50.20% / 73.81%.

     --stage-w 88.07%   makes frame 1 406.6 x 204.2 css px -- 8% down from
                        the artboard's 442. Shrinking moves frame 1, because it
     --stage-x 48.97%   sits off-centre inside the union box, so x and y are
     --stage-y 73.65%   re-solved to keep FRAME 1, not the box, on the centre.

   Verified at a 678px card: frame 1 sits in a 646.9 x 312.6 red field with
   120.1 / 120.1 px left and right, 54.2 / 54.2 px top and bottom. */
.poster__stage {
  position: absolute;
  left: var(--stage-x, 48.97%);
  top: var(--stage-y, 73.65%);
  transform: translate(-50%, -50%);
  width: var(--stage-w, 88.07%);
  aspect-ratio: 1653 / 572;
  pointer-events: none;
}
.poster__stage svg { display: block; }

/* ---------- parallax cards ----------
   Block: card-parallax.md. A clipped card holding stacked plates. Each plate
   declares where it STARTS (--x/--y, % of the card) and how far it TRAVELS
   (--dx/--dy, % of the plate) as the card crosses the viewport. The two
   percentages resolve against different boxes on purpose: an inset resolves
   against the containing block, a percentage inside translate() against the
   element's own box. Travel is a transform on top of a left/top start, so the
   browser never re-lays-out while you scroll -- it recomposites.
   overflow:hidden is the whole effect: the plates are larger than the card
   and hang outside it, and it is the card that crops them. */
.pcard {
  position: relative;
  overflow: hidden;
  background: var(--pc-field, #3C3B3B);
  aspect-ratio: var(--pc-card-aspect, 1416 / 802);
  --pc-p: 0;                 /* 0..1, written by initPcards() */
}
.pcard .pc-plate {
  position: absolute;
  display: block;
  height: auto;
  max-width: none;
  pointer-events: none;
  width: calc(var(--w, 100) * 1%);
  aspect-ratio: var(--ar, 1);
  left: calc(var(--x, 0) * 1%);
  top:  calc(var(--y, 0) * 1%);
  /* --pc-pp is this plate's own progress; the script writes it only for
     plates that declare a --lag. Everything else rides the card's --pc-p. */
  --p: var(--pc-pp, var(--pc-p));
  transform-origin: var(--org, 50% 50%);
  transform:
    translate3d(calc(var(--dx, 0) * var(--p) * 1%),
                calc(var(--dy, 0) * var(--p) * 1%), 0)
    rotate(calc((var(--r0, 0) + (var(--r1, 0) - var(--r0, 0)) * var(--p)) * 1deg))
    scale(calc(var(--s0, 1) + (var(--s1, 1) - var(--s0, 1)) * var(--p)));
}
/* A plate that wraps an image instead of being one -- for when the image
   needs a transform of its own, since the plate's is spent on the parallax. */
.pcard .pc-plate > img { display: block; width: 100%; height: 100%; }

/* ---------- laptop card ----------
   Block: card-laptop.md. The screen is a plate that CLIPS, and the page is an
   image taller than it. The laptop photo is opaque everywhere except the
   screen hole, so it is painted ON TOP: its bezel, rounded corners and chrome
   bar mask this plain rectangle, and no separate mask asset exists.
   height:auto is what makes the page tall and must come after the wrapped-
   plate rule above. The white ground is the page's own, so any subpixel seam
   under the bezel reads as screen rather than as the grey card behind it. */
.pcard .pc-screen { overflow: hidden; background: var(--screen-bg, #fff); }
.pcard .pc-screen > img {
  width: 100%;
  height: auto;
  transform: translate3d(0, calc(var(--sy, 0) * var(--p) * 1%), 0);
}

@media (prefers-reduced-motion: reduce) {
  .pcard { --pc-p: 1; }                        /* the resting composition */
  /* but a screen showing the middle of a page is not a resting state */
  .pcard .pc-screen > img { transform: none; }
}

/* ---------- work: the paired row ----------
   Two half cards side by side, the second dropped so they do not arrive
   together. --row-drop is a margin percentage, so it resolves against the
   COLUMN WIDTH (678 at the artboard), not the row height: 40% = 271px, which
   lands the laptop's bottom edge 14px under the poster's. Change the poster
   and the drop stays put; that is the intent. */
.work__row {
  width: 100%;
  display: grid;
  grid-template-columns: 47.88% 47.88%;
  justify-content: space-between;   /* 4.24% gutter -> 60px at the artboard */
  /* NOT align-items:start on the container. The two halves are sticky, and a
     grid item's containing block is its grid area -- so the area has to be
     taller than the item or there is no stick range. align-self:start on the
     items keeps each item content-sized inside a full-height area, which is
     exactly the shape sticky needs. min-height is written by the script. */
}
.work__row > .reveal { align-self: start; }
.work__row > :nth-child(2) { margin-top: var(--row-drop, 40%); }
/* inside the row the halves are already half-width columns */
.work__row .poster,
.work__row .pcard { width: 100%; }

/* ---------- small screens ---------- */
@media (max-width: 720px) {
  :root {
    --media-left: 50%; --media-width: 88%;  --media-top: 26%;
    --title-left: 6%;  --title-width: 88%;  --title-top: 34%;
  }
  .hero__title { font-size: clamp(2rem, 9.5vw, 3.5rem); }
  .subhead { padding: 0 6% 48px; }
}

@media (max-width: 860px) {
  .nav__inner { gap: 28px; padding-right: 6%; justify-content: center; }
  .cue { font-size: 64px; margin-bottom: 72px; }
  .work { padding: 0 6% 10vh; }
  .work > * + * { margin-top: 72px; }
  .poster { width: 100%; }

  /* the row stops being a row; the drop and the pinning go with it */
  .work__row { grid-template-columns: 1fr; gap: 72px; min-height: 0 !important; }
  .work__row > :nth-child(2) { margin-top: 0; }
  .reveal { position: static; }

  /* absolute layout stops making sense once the card is narrow */
  .card { aspect-ratio: auto; padding: 24px; }
  .card__media { position: relative; left: 0; top: 0; transform: none; width: 100%; aspect-ratio: 2200 / 1492; }
  .card__title { position: static; width: auto; margin: 20px 0 0; }
  .card__tags {
    position: static;
    flex-direction: row;
    flex-wrap: wrap;
    align-items: flex-start;
    justify-content: flex-start;
    margin-top: 16px;
  }
}

/* ---------- reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
  /* No scrub track, no sticky, no transforms: the hero collapses to one
     screen with everything already sitting where the scroll would have put
     it. Same composition, arrived at without motion.

     ⚠️ All THREE start positions have to be moved, not just the answer.
     These are the resting values from HERO in main.js -- questionRest,
     mediaRest, answerRest -- because with no transforms the CSS `top` is
     the only thing placing them. Moving the answer alone (as this block
     used to) left the question 80px + 9svh above its resting place, and
     the two boxes overlapped by 3-38px at 1300x743, 1440x900 and
     1920x1080. If a rest value changes in main.js, change it here too. */
  :root {
    --scrub: 0px;
    --title-top: 12.69vh;    /* HERO.questionRest */
    --media-top: 11.58vh;    /* HERO.mediaRest    */
    --answer-top: 64vh;      /* HERO.answerRest   */
  }
  .hero { position: static; }
  .hero__title, .hero__answer { transform: none !important; }

  .headline { opacity: 1; transform: none; filter: none; transition: none; }
  .subhead__inner { opacity: 1; transform: none; transition: none; }
  .caret { display: none; }
  .hero__media { transform: none !important; }
  .cue__glyph { animation: none; }
  .card__img--hover,
  .card__title span { transition: none; }
}

/* ============================================================================
   WORK GRID
   Moved out of grid-test.html 2026-08-23, when the grid replaced the pinned
   stack on index.html. The .work / .reveal rules above are kept: the stack
   still exists as a block and in the test pages.
============================================================================ */
/* ============================================================================
   WORK GRID -- three labelled categories of tiles, laid out on a 16-column
   grid. Figma frame "MacBook Pro 14" - 2", node 107:699.

   THE GRID. The artboard's tiles are all on a 16-column grid inside the 1416px
   content column, but the gutters it drew were 8px and hand-placed. The
   gutter is now a single value, 16px, horizontally AND vertically:

     16 columns x 73.5 + 15 gutters x 16 = 1416      column i starts at 48 + i*89.5

   The three tile widths in the design are 4, 6 and 8 columns -- 342, 521, 700
   (they were 348, 526, 704 at the artboard's 8px gutter). Column indices are
   unchanged, so the composition is the artboard's; only the air between the
   tiles moved.

   HOW A TILE IS PLACED. Horizontal positions are percentages of the content
   column; vertical positions are percentages of the SECTION's own height, and
   the section gets that height from an aspect-ratio. So one number -- --gh,
   the band's height -- makes every top and height in that section resolve, and
   the whole band scales with the viewport exactly like the cards inside it do.

   NOTHING IS CROPPED. Every tile takes its HEIGHT from the proportion of the
   file inside it -- the grid gives it a column and a width, the content gives
   it a height. So `object-fit:cover` never has anything to cut off, and the
   only thing that ever overflows a tile is the hover zoom. Where a column
   holds two tiles the second sits 16px under the first, and the band's height
   is the tallest column.

   Default to the WHOLE file, transparent margin included -- that margin is
   usually a soft shadow and part of the composition (the rooster brochure's
   is 80/88/113/113 and fades over ~3px). The exception is a hard-edged flat
   export sitting in asymmetric padding: the book parallax card is a crisp
   rectangle inside 150/232/146/148 of dead alpha, which reads as the artwork
   being off-centre and undersized in its tile. That one is trimmed to the
   alpha bbox so the card fills the tile. Measure before deciding -- compare
   the bbox at alpha>0 with the bbox at alpha>250: if they are the same, the
   edge is hard and the margin is export slop.

   THE SELECTION BOX. Every tile wears a Figma selection frame: a 1px stroke
   plus four small solid square handles at the corners. The stroke is an
   `outline`, not a border, so it neither eats into the tile's box nor shifts
   the artwork inside it. The four handles are the tile's two pseudo-elements
   plus the two on `.hs`.
============================================================================ */

:root {
  --sel: #4C4C4C;          /* the artboard's own placeholder stroke colour */
  --handle: 7px;
  --ph: #D9D9D9;           /* placeholder plate, as in the artboard */
}

/* On index.html this is a band inside <main>, under the hero: it must paint
   above the sticky hero, and the gap above it is the cue's margin (112px on
   the grid artboard, set there). --grid-top is for grid-test.html, which has
   no hero above it. */
.work-grid {
  position: relative;
  z-index: 2;
  padding: var(--grid-top, 0) 3.1746% 14vh;   /* artboard margin 48 / 1512 */
}

/* ---------- category ---------- */
.cat + .cat { margin-top: var(--cat-gap); }   /* % of width, so it scales */

.cat__head h2 {
  margin: 0;
  font-family: "Archivo", system-ui, sans-serif;
  font-weight: 400;                           /* regular, not bold */
  font-size: clamp(16px, 1.5873vw, 24px);     /* artboard 24 */
  line-height: 1.1;
  color: var(--ink);
}

/* Marker highlight, always on -- same gradient trick as .card__title span,
   but not hover-driven: these headings wear it at rest. The stripe is on an
   inline <span> inside the h2 so it hugs the words instead of the full
   1416-wide column, and box-decoration-break keeps it right if one wraps.
   The negative margin cancels the padding, so the overshoot at each end
   does not push the text off its artboard x. */
.cat__head h2 span {
  background-image: linear-gradient(var(--lime), var(--lime));
  background-repeat: no-repeat;
  background-position: 0 0.08em;
  background-size: 100% 1.02em;
  padding: 0 0.16em;
  margin: 0 -0.16em;
  -webkit-box-decoration-break: clone;
          box-decoration-break: clone;
}

.cat__head p {
  margin: calc(17 / 1416 * 100%) 0 0;         /* artboard 1366 -> 1383 */
  /* ⚠️ Measure is a guess, not off the artboard -- the artboard only ever
     had "Lorem" here, so there is no line to match. 700/1416 keeps the copy
     to roughly 75 characters; line-height opens from the heading's 1.1
     because this is now a real paragraph, not a one-word label. */
  max-width: calc(var(--cat-copy, 700) / 1416 * 100%);
  font-family: "Archivo", system-ui, sans-serif;
  font-weight: 400;
  font-size: clamp(12px, 1.0582vw, 16px);     /* artboard 16 */
  line-height: 1.35;
  color: var(--ink);
}

/* The band is sized by its own height, so every % inside it resolves. */
.cat__grid {
  position: relative;
  width: 100%;
  aspect-ratio: 1416 / var(--gh);
  margin-top: var(--head-gap);
}

/* ---------- tile ----------
   ⚠️ The geometry vars are --tx/--ty/--tw/--th, NOT --x/--y/--w/--h. Custom
   properties inherit, and card-parallax's plates read `var(--w, 100)` off
   their own inline style with a fallback -- so a plate that declares no --w
   silently inherits the TILE's, and the laptop rendered at 521% of its card.
   Anything nested in a tile that uses short prop names is exposed to this. */
.tile {
  position: absolute;
  left:   calc(var(--tx) / 1416 * 100%);
  width:  calc(var(--tw) / 1416 * 100%);
  top:    calc(var(--ty) / var(--gh) * 100%);
  height: calc(var(--th) / var(--gh) * 100%);
  outline: 1px solid var(--sel);              /* drawn outside the box, no layout cost */
}
/* The clip. Kept off .tile itself so the corner handles, which hang half
   outside, are not cut off by it. */
.tile__box {
  position: absolute;
  inset: 0;
  overflow: hidden;
  background: var(--field, transparent);
}
/* The tile is cut to the picture's proportion, so cover has nothing to crop --
   it is left in only as a guard against a source being replaced by one of a
   different shape. */
.tile__box > img {
  display: block;
  width: 100%; height: 100%;
  object-fit: cover;
  transition: transform 700ms cubic-bezier(.22, .61, .21, 1);
}
/* Gentle zoom on hover, stills only. Scale, not a change of size: it is paint,
   not layout, so nothing around it moves. The tile is what clips it. */
.tile:hover .tile__box > img { transform: scale(1.045); }

/* Four corner handles: .tile::before/::after + .hs::before/::after. */
.tile .hs { position: absolute; inset: 0; pointer-events: none; }
.tile::before, .tile::after,
.tile .hs::before, .tile .hs::after {
  content: "";
  position: absolute;
  width: var(--handle); height: var(--handle);
  background: var(--sel);
  pointer-events: none;
  z-index: 3;
}
.tile::before        { left: 0;  top: 0;    transform: translate(-50%, -50%); }
.tile::after         { right: 0; top: 0;    transform: translate( 50%, -50%); }
.tile .hs::before    { left: 0;  bottom: 0; transform: translate(-50%,  50%); }
.tile .hs::after     { right: 0; bottom: 0; transform: translate( 50%,  50%); }

/* ---------- tile contents ---------- */

/* A slot with no artwork yet. Same box, same selection frame, so the
   composition reads correctly and swapping in the real thing is one element. */
.slot {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  background: var(--ph);
  font-family: "Archivo", system-ui, sans-serif;
  font-size: clamp(11px, 0.9259vw, 14px);
  color: #5A5A5A;
  text-align: center;
  padding: 8%;
}

/* The tile is cut to the poster's own 2000/2157, so the poster simply fills
   it and --stage-x/--stage-y stay exactly as the block measured them. */
.tile .poster { position: absolute; inset: 0; width: 100%; }

/* A bare Lottie on a field -- no still underneath, so no .poster__bg. */
.logo-stage {
  position: absolute;
  left: 50%; top: 50%;
  transform: translate(-50%, -50%);
  width: var(--logo-w, 78%);
  aspect-ratio: 1096 / 196;
}
.logo-stage svg { display: block; }

/* Cards fill their tile. */
.tile .pcard { width: 100%; height: 100%; }

@media (prefers-reduced-motion: reduce) {
  .tile__box > img { transition: none; }
  .tile:hover .tile__box > img { transform: none; }
}

@media (max-width: 860px) {
  /* Not designed yet -- the bands unpack into one column so the page is at
     least readable on a phone. */
  .work-grid { padding: 72px 6% 10vh; }
  .cat__grid { aspect-ratio: auto; display: grid; gap: 16px; }
  .tile { position: relative; left: auto; top: auto; width: 100%; height: auto;
          aspect-ratio: var(--tw) / var(--th); }
}

/* ============================================================================
   FOOTER                                       Figma node 125:643, 1512 x 1741

   Flow, not absolute. The grid above places tiles by percentage of a band
   whose height is fixed by an aspect-ratio, which works because tiles are
   pictures and scale. This is type: the sizes clamp, so a fixed aspect would
   pull the composition apart at the ends of the range. Gaps are percentages
   of 1512 instead, and every element was then measured back onto its
   artboard y at the reference width (see the table in the block doc).

   FULL BLEED. The painting runs 0..1512, so <footer> sits outside
   .work-grid's 3.1746% padding and the type gets its measure from its own
   max-width rather than from a padded column.
============================================================================ */

.foot {
  --foot-blur: 58px;              /* the ramp's MAXIMUM, reached at the bottom */
  --foot-veil: 0;                 /* optional flat tint in the page colour */
  position: relative;
  z-index: 2;                     /* same reason as .work-grid: sticky hero */
  background: var(--bg);
  padding-top: calc(255 / 1512 * 100%);   /* artboard y of the lead */
  overflow: hidden;
}

/* ---------- type ---------- */

.foot__lead,
.foot__head {
  margin: 0 auto;
  max-width: calc(836 / 1512 * 100%);
  font-family: "PP Editorial Old", Georgia, serif;
  font-weight: 400;               /* artboard says Medium; only Regular is installed */
  font-size: clamp(22px, 2.381vw, 36px);      /* artboard 36 */
  line-height: 1.1;
  text-align: center;
  color: var(--ink);
  font-variant-ligatures: common-ligatures discretionary-ligatures;
  -webkit-font-feature-settings: "liga" 1, "dlig" 1;
          font-feature-settings: "liga" 1, "dlig" 1;
}

.foot__link {
  margin: calc(99.8 / 1512 * 100%) auto 0;    /* lead bottom -> 434 */
  text-align: center;
}
.foot__link a {
  font-family: "Archivo", system-ui, sans-serif;
  font-weight: 500;                            /* artboard Medium */
  font-size: clamp(18px, 1.5873vw, 24px);      /* artboard 24 */
  line-height: 1.1;
  color: var(--link);
  text-decoration: underline;
  text-underline-position: from-font;
  text-decoration-thickness: from-font;
}
/* The lime marker again, on the one link in the page. Same values as the
   category heads, so it reads as the same pen. */
.foot__link a {
  background-image: linear-gradient(var(--lime), var(--lime));
  background-repeat: no-repeat;
  background-position: 0 0.08em;
  background-size: 0% 1.02em;
  padding: 0 0.16em;
  margin: 0 -0.16em;
  -webkit-box-decoration-break: clone;
          box-decoration-break: clone;
  transition: background-size 420ms cubic-bezier(.16,1,.3,1);
}
.foot__link a:hover,
.foot__link a:focus-visible { background-size: 100% 1.02em; }

.foot__head { margin-top: calc(208 / 1512 * 100%); }   /* -> 671 */

/* ⚠️ The LinkedIn link is NOT on the artboard, so it has no y to land on.
   It takes a 64 gap under the about copy and the painting keeps its own
   measured 117.1 below it -- so everything from here down has moved 93px and
   the footer is 1834 rather than the frame's 1741. Every artboard y ABOVE
   this element still holds exactly. */
/* ⚠️ 57.3, not 64. The icon link carries 0.28em of padding for its hover
   chip, so ~6.7px of the gap on each side is inside the box. The margins are
   the intended 64 / 117.1 MINUS that padding, which is what keeps the visual
   gaps right and the painting on 1150. */
.foot__link--tail { margin-top: calc(57.3 / 1512 * 100%); }

/* The LinkedIn mark. currentColor, so it takes --link exactly like the other
   link's text does and the two read as one pair rather than as a brand badge
   dropped into the page. The official blue (#0A66C2) is deliberately not
   used -- it is 8 levels off --link and would look like a mistake.

   ⚠️ The marker highlight has to be re-sized here. The shared rule measures
   the stripe in `em` against the 24px font, but what is on screen is a 28px
   square with no text in it, so the stripe came up short of the mark. On the
   tail it is `100% 100%` of the padded box instead -- a lime chip that grows
   out from the left, same easing, same 420ms. */
.foot__link--tail a {
  display: inline-flex;
  padding: 0.28em;
  margin: 0;
  text-decoration: none;
  background-position: 0 0;
  background-size: 0% 100%;
}
.foot__link--tail a:hover,
.foot__link--tail a:focus-visible { background-size: 100% 100%; }

.foot__link--tail svg {
  display: block;
  width:  clamp(21px, 1.8519vw, 28px);   /* 28 at the 1512 reference */
  height: clamp(21px, 1.8519vw, 28px);
  fill: currentColor;
}

.foot__about {
  margin: calc(83.4 / 1512 * 100%) auto 0;     /* -> 794 */
  max-width: calc(661 / 1512 * 100%);
  font-family: "Archivo", system-ui, sans-serif;
  font-weight: 400;
  font-size: clamp(14px, 1.0582vw, 16px);      /* artboard says 20; 16 on request */
  line-height: 1.1;
  text-align: center;
  color: var(--muted-ink);
}
.foot__about p { margin: 0; }
/* The artboard separates these with an EMPTY paragraph, not a margin:
   18 + a 22px blank line + 18. Reproduced as one 58px gap rather than an
   empty <p>, which a screen reader would announce as a blank item. */
.foot__about p + p { margin-top: calc(58 / 661 * 100%); }

/* ---------- the painting ---------- */

.foot__art {
  position: relative;
  margin-top: calc(110.4 / 1512 * 100%);       /* -> 1150; see the tail note */
  width: 100%;
  aspect-ratio: 1512 / 684;
  overflow: hidden;
}

/* The picture is NEVER filtered -- it stays sharp, at its own edges, and the
   blur is applied on top of it. */
.foot__art img {
  position: absolute;
  inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
}

/* ⚠️ Stacked copies with `filter`, NOT panes of `backdrop-filter`.

   backdrop-filter is the obvious way to ramp a blur and it was the first
   build: five transparent panes over one sharp picture, each masked to fade
   in. It looks identical and it costs three times the frame budget --
   **33.6 fps against 60.2** measured scrolling the homepage past the footer,
   because a backdrop-filter has to re-sample what is behind it every frame;
   the browser cannot know the backdrop is a static picture.

   A `filter` on an <img> rasterises once and is then just a texture, so
   scrolling past it is free. The cost is five decodes of the same file (one
   request, ~20 MB of layer memory at 1512 x 684) instead of one.

   Each copy is blurred by ITS OWN radius -- these do not compose, unlike the
   backdrop version, so --b spans the whole range up to 1.

   ⚠️ The mask goes on the WRAPPER, the inset on the <img>. A blur bleeds the
   edge inwards by roughly its radius, so each blurred copy has to start
   outside the frame on all four sides -- and the mask has to measure against
   the frame, not against the inflated picture. Two elements, one job each.
   (scale() cannot do the inset: the box is 2.21:1, so a factor that covers
   the radius vertically overshoots by 2.2x horizontally.) */
.foot__haze {
  position: absolute;
  inset: 0;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(to bottom, transparent var(--from), #000 var(--to));
          mask-image: linear-gradient(to bottom, transparent var(--from), #000 var(--to));
}
.foot__haze img {
  --r: calc(var(--foot-blur) * var(--b));
  position: absolute;
  inset:  calc(var(--r) * -2);
  width:  calc(100% + var(--r) * 4);
  height: calc(100% + var(--r) * 4);
  object-fit: cover;
  filter: blur(var(--r));
}

/* ⚠️ NO fade into the page at the top. There was one -- the page colour held
   over the first 12% and released -- and it read as blur, which is exactly
   what the ramp is supposed to be free of up there. A soft wash of flat
   colour over a busy painting is indistinguishable from a soft focus, so the
   band went blurry / sharp / blurry down the page. The artboard butts the
   picture straight against the page anyway; a hard edge is the design.

   --foot-veil stays as a flat tint for knocking the picture back, at 0. */
.foot__art::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: rgba(224, 214, 209, var(--foot-veil));
}

@media (max-width: 860px) {
  .foot { --foot-blur: 34px; padding-top: 96px; }
  .foot__lead, .foot__head, .foot__about { max-width: 86%; }
  .foot__link { margin-top: 44px; }
  .foot__head { margin-top: 96px; }
  .foot__about { margin-top: 20px; }
  .foot__art  { margin-top: 56px; }
}
