/* ============================================================
   Fonts — Swiss 721 throughout, per the Figma design.
   Three families: normal widths, extended widths, outline cuts.
   ============================================================ */
@font-face {
  font-family: 'Swiss 721';
  src: url('assets/fonts/Swiss721-Regular.woff2') format('woff2');
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: 'Swiss 721';
  src: url('assets/fonts/Swiss721-Medium.woff2') format('woff2');
  font-weight: 500; font-style: normal; font-display: swap;
}
@font-face {
  font-family: 'Swiss 721';
  src: url('assets/fonts/Swiss721-Bold.woff2') format('woff2');
  font-weight: 700; font-style: normal; font-display: swap;
}
@font-face {
  font-family: 'Swiss 721';
  src: url('assets/fonts/Swiss721-Heavy.woff2') format('woff2');
  font-weight: 800; font-style: normal; font-display: swap;
}
@font-face {
  font-family: 'Swiss 721';
  src: url('assets/fonts/Swiss721-Black.woff2') format('woff2');
  font-weight: 900; font-style: normal; font-display: swap;
}

@font-face {
  font-family: 'Swiss 721 Extended';
  src: url('assets/fonts/Swiss721-LightExtended.woff2') format('woff2');
  font-weight: 300; font-style: normal; font-display: swap;
}
@font-face {
  font-family: 'Swiss 721 Extended';
  src: url('assets/fonts/Swiss721-RegularExtended.woff2') format('woff2');
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: 'Swiss 721 Extended';
  src: url('assets/fonts/Swiss721-BoldExtended.woff2') format('woff2');
  font-weight: 700; font-style: normal; font-display: swap;
}

@font-face {
  font-family: 'Swiss 721 Outline';
  src: url('assets/fonts/Swiss721-BoldOutline.woff2') format('woff2');
  font-weight: 700; font-style: normal; font-display: swap;
}
@font-face {
  font-family: 'Swiss 721 Outline';
  src: url('assets/fonts/Swiss721-BlackOutline.woff2') format('woff2');
  font-weight: 900; font-style: normal; font-display: swap;
}

/* ============================================================
   Fluid root — locked at 16px to 1440px, then scales as one unit.
   The Figma frame is drawn at 1920, where this root resolves to
   22px. That is the conversion for the whole design:
       rem = Figma px ÷ 22
   ============================================================ */
html { font-size: 1rem; }

@media screen and (min-width: 1441px) and (max-width: 1680px) {
  html { font-size: calc(0.25rem + 0.8333vw); }   /* 16 → 18px */
}
@media screen and (min-width: 1681px) and (max-width: 1920px) {
  html { font-size: calc(-0.625rem + 1.6667vw); } /* 18 → 22px */
}
@media screen and (min-width: 1921px) and (max-width: 2560px) {
  html { font-size: calc(0.25rem + 0.9375vw); }   /* 22 → 28px */
}

/* ============================================================
   Design tokens
   ============================================================ */
:root {
  /* Colour */
  --black: #211f1f;
  --off-white: #f5f2ed;
  /* THE BLOB'S SIZE, and the one number anything in proportion to it derives
     from. Capped on both axes: the height cap keeps it sane on a short wide
     window, the width cap on a tall narrow one. See .blob-slot. */
  --blob-size: min(105dvh, 61vw);
  /* The blob is centred in the viewport less this at the bottom (.blob-slot),
     so its horizontal centre line sits at --blob-axis from the top of the
     screen. Anything that has to sit level with the blob reads that token
     rather than 50vh, so the two can never part. */
  --blob-lift: 1.92rem;
  --blob-axis: calc((100dvh - var(--blob-lift)) / 2);

  --accent: #ee3335;
  /* Text where it crosses the blob. --off-white on the flat blob red measures
     5.15:1 and passes AA; --black on it measures 2.85:1 and does not, so the
     inversion is what keeps this text readable, not only what makes it read. */
  --on-blob: var(--off-white);

  /* THE GRID — read directly off the Figma root frame's layout grid:
     COLUMNS, count 12, gutterSize 32, offset 54, alignment STRETCH.
     Everything horizontal in this file is placed on it. */
  --grid-margin: 2.4545rem;  /* 54 — vertical rhythm only; see --page-gutter */

  /* Height of the fixed bottom bar: the sine wave at 12.5rem wide is the
     tallest thing in it (its artwork renders at 0.517 of its width). The hero
     reserves this as padding, so change the wave's width and change this too. */
  --bottom-bar-h: 6.1099rem;

  /* The page gutter is the frame's own 54px margin. It used to be derived
     from the background grid texture so content sat flush inside that
     texture's outermost lines — the texture is gone from the design, so the
     margin comes straight off the layout grid now and scales with the
     fluid root like everything else. */
  --page-gutter: 2.4545rem;  /* 54 — the layout grid's margin */
  /* The header's own side margin. The page margin everywhere except phones,
     where the "Menu" frame sets the header on a wider one (see the 767 block). */
  --header-gutter: var(--page-gutter);
  /* Where the open mobile menu's word list starts, from the top of the
     screen: puts the first word's baseline where the frame does. */
  --menu-top: 12.0625rem;   /* 193 — first baseline at the frame's 220 */
  --grid-gutter: 1.4545rem;  /* 32 — between columns */

  /* Vertical spacing, in the design's own steps */
  --gap-1: 0.5rem;
  --gap-2: 1rem;
  --gap-3: 2rem;
  --gap-4: 3rem;
  --gap-5: 4rem;   /* 88 — the gap between sections */
  --gap-6: 6rem;

  /* THE ONE DISTANCE both section headings sit below their section's top edge
     — which, after a nav click lands the section at the top of the screen, is
     their distance below the header. "WHO I AM" and "WHAT I DO" both read it,
     so they can't drift apart. 19px clear of the logotype at 1920. */
  --heading-top: 5.1364rem;  /* 113 */
  /* The heading's own line box: --fs-logo at .display-outline's 1.2 leading.
     The project grid's height budget subtracts it. */
  --heading-h: calc(var(--fs-logo) * 1.2);
  /* The "Who I am" grid's own top padding. It places the portrait; the
     heading is lifted from it to --heading-top. */
  --who-top: 3.7727rem;      /* 83 */

  /* Type scale — Figma px ÷ 22 */
  --fs-nav: 1.4545rem;      /* 32 */
  --fs-body: 1.091rem;      /* 24 */
  --fs-logo: 1.4545rem;     /* 32 */
  --fs-project: 1.455rem;   /* 32 */
  --fs-statement: 1.818rem; /* 40 */
  /* The keyword type is derived from the blob's own size expression, not from
     the root. Both lists have to stay in proportion to the blob at every
     desktop width, and the root cannot do that: it is locked at 16px below
     1440 while the blob keeps tracking the viewport, so the type drifts
     against it. 0.0441 is the ratio that reproduces the 50px the 1920 frame
     asks for, and it then holds at 6.7% of the blob's rendered width all the
     way down. The floor meets the mobile value so the two are continuous. */
  --fs-keyword: clamp(1.75rem, calc(var(--blob-size) * 0.0441), 4rem);
  --fs-display: 3.1818rem;  /* 70 */
  --fs-section: 2.2727rem;  /* 50 — the project page's title and section headings */

  --track: 0.025rem;        /* 0.55 */
  --track-display: 0.2273rem; /* 5 — the wide tracking on the 70px headings,
                                 the footer line and the ticker */
}

/* ============================================================
   Reset + base
   ============================================================ */
* { margin: 0; padding: 0; box-sizing: border-box; }

body {
  font-family: 'Swiss 721', sans-serif;
  color: var(--black);
  background: var(--black); /* shows behind the revealed footer */
  font-synthesis: none;
}

/* ============================================================
   THE GRID SYSTEM
   ------------------------------------------------------------
   .u-container — the 54px margin, nothing else.
   .u-grid      — the 12 columns and 32px gutters.
   Children place themselves with grid-column, and offset
   vertically with margin-top. Everything sits on row 1, so
   column placement alone decides horizontal position — which is
   how the Figma frame is built.
   ============================================================ */
.u-container {
  width: 100%;
  padding: 0 var(--page-gutter);
}

.u-grid {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  column-gap: var(--grid-gutter);
  align-items: start;
}

.u-grid > * { grid-row: 1; }

/* Column spans used by the design. Named so future sections can
   reuse them instead of re-deriving column maths. */
.col-1      { grid-column: 1 / 2; }
.col-1-4    { grid-column: 1 / 5; }
.col-1-7    { grid-column: 1 / 8; }
.col-1-8    { grid-column: 1 / 9; }
.col-1-12   { grid-column: 1 / 13; }
.col-2-4    { grid-column: 2 / 5; }
.col-2-6    { grid-column: 2 / 7; }
.col-2-7    { grid-column: 2 / 8; }
.col-2-5    { grid-column: 2 / 6; }
.col-2-8    { grid-column: 2 / 9; }
.col-2-11   { grid-column: 2 / 12; }
.col-2-12   { grid-column: 2 / 13; }
.col-5-8    { grid-column: 5 / 9; }
.col-7-11   { grid-column: 7 / 12; }
.col-8-11   { grid-column: 8 / 12; }
.col-9-11   { grid-column: 9 / 12; }
.col-9-12   { grid-column: 9 / 13; }
.col-10-12  { grid-column: 10 / 13; }

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

/* ============================================================
   Type
   ============================================================ */
/* Named for the Outline cut it used to use. The frame now sets these in
   Swiss 721 Extended, so nothing on the page is hollow any more — the class
   name is kept so the markup and the docs stay readable.

   Sized and tracked to match the header logo exactly (--fs-logo, 0.05em),
   not the display scale its own name still points at — requested directly.
   Family, weight and colour already matched .logo before this, so those two
   properties are the only ones that needed to change. */
.display-outline {
  font-family: 'Swiss 721 Extended', sans-serif;
  font-weight: 400;
  font-size: var(--fs-logo);
  line-height: 1.2;
  letter-spacing: 0.05em;
  color: var(--black);
}

.body-text {
  font-family: 'Swiss 721', sans-serif;
  font-weight: 500;              /* Medium */
  font-size: var(--fs-body);
  line-height: 1.458;   /* 35 / 24 */
  letter-spacing: var(--track);
}

/* ============================================================
   Reusable pieces — "Contact" and "WORDS" are named frames in
   Figma and are used in both the hero and the footer.
   ============================================================ */
/* The frame's contact row is 520 wide — wider than any three-column span — and
   ends on the col-11 edge. So it spans col 8-11 and packs to the right: the
   right edge lands exactly where the frame puts it and the row grows leftward
   to whatever the type needs. */
.contact-links {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 2.409rem; /* 53 */
  list-style: none;
}

/* Plain text, not capsules. The frame's link frames still carry a 30 corner
   radius from when they were outlined, but they have no stroke and no fill —
   what it draws is bare type. */
.contact-links :is(a, button) {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  font-family: 'Swiss 721', sans-serif;
  font-weight: 500;              /* Medium */
  font-size: var(--fs-nav);
  line-height: 1.2;
  color: var(--black);
  text-decoration: none;
  white-space: nowrap;
}

/* The Email entry is a <button>, not a link: it copies the address rather
   than opening a mail app (site-header.js). This strips the browser's button
   chrome so it renders exactly as the links beside it — the rule above
   already sets its face, size, colour and line-height. */
.contact-links button {
  margin: 0;
  padding: 0;
  border: 0;
  background: none;
  letter-spacing: inherit;
  text-align: inherit;
  -webkit-appearance: none;
  appearance: none;
  cursor: pointer;
}

/* ONE rule for both keyword lists, at every width and every height.

   The gap is a fraction of the keyword size rather than a fixed length, so the
   step between lines holds its proportion automatically however big the type
   gets — 0.58 of it is the 29 the 1920 frame draws against its 50px type. Nothing
   overrides either the size or the spacing per list or per breakpoint; the only
   thing scoped to .section-footer is colour. */
.words {
  display: flex;
  flex-direction: column;
  /* Off the keyword size itself, not em: em on this element would resolve
     against the list's own inherited font-size, which is the body's, not the
     keywords'. */
  gap: calc(var(--fs-keyword) * 0.58);   /* 29 against the frame's 50 */
  list-style: none;
}

.words li {
  font-family: 'Swiss 721 Extended', sans-serif;
  font-weight: 400;
  font-size: var(--fs-keyword);
  line-height: 1.2;
  color: var(--black);
}

/* ------------------------------------------------------------------
   Mask reveal — ported from TEXT FOLD/MaskReveal.tsx.

   A clip-path wipe: the mask starts covering the element and retreats
   from the left edge, so the word is uncovered left to right. That is
   the whole effect — no text splitting, which means the words stay as
   real, unbroken text for screen readers and for selection.

   main.js wraps each <li>'s contents and adds .is-revealed on its cue.
   Nothing is hidden by CSS alone: with JS off the wrapper is never
   built, so the words simply render as normal text.
   ------------------------------------------------------------------ */
.mask-reveal {
  display: inline-block;
  /* direction: left-to-right — the component's START clip */
  clip-path: inset(0% 100% 0% 0%);
  will-change: clip-path;
  transition: clip-path
              var(--mask-duration, 1s)
              var(--mask-ease, cubic-bezier(0.16, 1, 0.3, 1))
              var(--mask-delay, 0s);
}

/* `bleed` in the component: the finished clip is grown by a few px so a
   display face that overhangs its box is not shaved off permanently. */
.mask-reveal.is-revealed {
  clip-path: inset(var(--mask-bleed, 0px));
}

/* Reduced motion: show the text, skip the wipe — as the component does. */
@media (prefers-reduced-motion: reduce) {
  .mask-reveal {
    clip-path: inset(var(--mask-bleed, 0px)) !important;
    transition: none !important;
    will-change: auto;
  }
}

/* The last item in each list was the Outline cut. The frame now sets all five
   the same, so this no longer changes the face — the class is left on the
   markup because the lists are still built around it. */
.words .is-outline {
  font-family: 'Swiss 721 Extended', sans-serif;
  font-weight: 400;
}

/* ============================================================
   Header — Figma frame 19:97: x 54 (col 1), width 1813 (to the
   col-12 edge), y 53, height 70, 22px vertical padding.
   ============================================================ */
.section-header {
  position: fixed;
  top: 1rem; /* 22 — sits closer to the top edge */
  left: 0; right: 0;
  z-index: 20;
  background: transparent;
}

.section-header .u-container { padding-inline: var(--header-gutter); }

.header-grid {
  align-items: center;
  padding: 1rem 0; /* 22 */
}

/* The header mark. A real blob instance in the page (blob-scene.js draws its
   canvas in here), and a LINK: back to the top of the homepage, from every
   page (site-header.js). The whole box is the link, and the box is the
   canvas, so the clickable area is never smaller than the blob you see — it
   is larger, since the blob sits inside its canvas with room to spike. Sizing
   stays in rem so it keeps scaling with the fluid root; `position: relative`
   is what the .host boxes inside resolve to. */
.logo-blob {
  grid-column: 1 / 2;
  position: relative;
  display: block;
  flex: none;   /* never shrink */
  width: 3.273rem;   /* 72 */
  height: 2.773rem;  /* 61 */
  border-radius: 50%;   /* rounds the focus ring; nothing is clipped */
  -webkit-tap-highlight-color: transparent;
}

/* The same keyboard ring as the menu circle and the menu links. --accent reads
   on both grounds, so it holds over the footer too. */
.logo-blob:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

.logo {
  grid-column: 2 / 6;
  align-self: center;
  font-family: 'Swiss 721 Extended', sans-serif;
  font-weight: 400;
  font-size: var(--fs-logo);
  line-height: 1.2;
  letter-spacing: 0.05em;   /* the frame tracks the logotype at 5% */
  color: var(--black);
  white-space: nowrap;
}

.nav {
  grid-column: 9 / 12;
  display: flex;
  justify-content: flex-end;
  gap: 3rem; /* 66 */
}

.nav a {
  font-family: 'Swiss 721', sans-serif;
  font-weight: 500;              /* Medium */
  font-size: var(--fs-nav);
  line-height: 1.1;
  letter-spacing: var(--track);
  color: var(--accent);
  text-decoration: none;
  white-space: nowrap;
}

/* Over the inverted footer the header background stays transparent —
   only the logotype's ink flips, so it stays readable on black. */
.section-header.is-over-footer .logo { color: var(--off-white); }
.section-header.is-over-footer .menu-toggle { color: var(--off-white); }

/* ------------------------------------------------------------------
   Mobile menu (991px and below) — read off the "Menu" frame (393x852).
   Hidden above 991px, where the nav sits in the header bar and the
   contact links in the fixed bottom bar.

   A full-screen panel on the light ground that slides in from the LEFT
   under the header, which stays on top with its circle filled. The words
   then wipe in left to right with the site's own mask reveal (.mask-reveal,
   the keyword reveal's class and transition, toggled by site-header.js). Closing
   runs the same thing back: the words wipe out first, then the panel
   slides away to the left.

   Timing is the site's reveal vocabulary: 1s on cubic-bezier(0.16, 1, 0.3,
   1), the curve every reveal here uses. The words trail the panel by 0.15s
   on the way in and lead it by the same on the way out, so the two read as
   one movement in a fixed order. Reduced motion: no slide, no wipe — it
   simply appears and disappears.
   ------------------------------------------------------------------ */
.menu { display: none; }

/* The trigger. A real <button> with a name (aria-label) and its state in
   aria-expanded. The frame's 29px ring is drawn on ::before; the button
   around it is a 44px tap target — the comfortable minimum — pulled back by
   a negative margin of (44 - 29) / 2 on every side, so the RING sits on the
   page margin and the header row keeps its height. `currentColor`, so it
   inverts over the footer with the rest of the header. */
.menu-toggle {
  position: relative;
  display: block;
  width: 2.75rem;            /* 44 — tap target */
  height: 2.75rem;
  margin: -0.46875rem;       /* (44 - 29) / 2 */
  padding: 0;
  border: 0;
  border-radius: 50%;
  background: none;
  color: var(--black);
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
}
.menu-toggle::before {
  content: '';
  position: absolute;
  inset: 0;
  margin: auto;
  width: 1.8125rem;          /* 29 */
  height: 1.8125rem;
  box-sizing: border-box;
  border: 2px solid currentColor;
  border-radius: 50%;
  transition: background-color 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Filled while the menu is open, as the frame draws it. */
.menu.is-open .menu-toggle::before { background-color: currentColor; }

/* The panel. Fixed to the viewport, inside the header's own stacking context
   at z-index -1: under the header's mark, logotype and circle, over the whole
   page. Closed it sits one screen to the left and goes `visibility: hidden`
   only once it has slid out — the visibility step is delayed by the slide —
   so nothing in it can be reached while it is away. */
.menu-panel {
  position: fixed;
  inset: 0;
  z-index: -1;
  overflow-y: auto;          /* a short phone scrolls the list, never clips it */
  padding: var(--menu-top) var(--header-gutter) var(--gap-5);
  background: var(--off-white);
  transform: translateX(-100%);
  visibility: hidden;
  transition: transform 1s cubic-bezier(0.16, 1, 0.3, 1) 0.15s,
              visibility 0s linear 1.15s;
}
.menu.is-open .menu-panel {
  transform: none;
  visibility: visible;
  transition: transform 1s cubic-bezier(0.16, 1, 0.3, 1) 0s,
              visibility 0s linear 0s;
}

/* The words trail the panel in, lead it out. */
.menu-panel .mask-reveal { --mask-delay: 0s; }
.menu.is-open .menu-panel .mask-reveal { --mask-delay: 0.15s; }

/* Type and rhythm, all off the frame at 393 (ink measured, then solved
   against the real faces): every word is Swiss 721 Medium at 32px; the two
   section links sit 75 apart baseline to baseline, the three contact links
   93 apart, with 131 between the groups; the contact links start 7px in
   from the section links. Line-height 1, so each gap below is pitch - 32. */
.menu-nav {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2.6875rem;            /* 75 - 32 */
}

.menu-nav a,
.menu-panel .menu-contact :is(a, button) {
  display: inline-block;
  font-family: 'Swiss 721', sans-serif;
  font-weight: 500;          /* Medium, same as the desktop nav */
  font-size: 2rem;           /* 32 */
  line-height: 1;
  letter-spacing: var(--track);
  text-decoration: none;
  white-space: nowrap;
}
.menu-nav a { color: var(--accent); }
.menu-panel .menu-contact :is(a, button) { color: var(--black); }

/* .menu-contact is also a .contact-links list; these undo that list's
   desktop row (flex-end, its gap) and the 991 block's wrap. */
.menu-panel .menu-contact {
  flex-direction: column;
  flex-wrap: nowrap;
  align-items: flex-start;
  justify-content: flex-start;
  gap: 3.8125rem;            /* 93 - 32 */
  margin-top: 6.1875rem;     /* 131 - 32 */
  padding-left: 0.4375rem;   /* 7 — the frame's indent */
}

.menu-toggle:focus-visible,
.menu-nav a:focus-visible,
.menu-contact :is(a, button):focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
}

/* While the menu is open the header sits on the light panel whatever is
   behind it, so its ink is the light-ground ink even over the footer, and
   the white copies of the logotype (clipped to the blob, which is now
   covered) stay off. The page behind cannot scroll. */
/* ------------------------------------------------------------------
   "Email copied" notification — the Email entries (bottom bar, mobile
   menu) copy the address instead of opening a mail app, and this says so.
   One per page, inside the shared header (site-header.js): the header's
   layer sits over the page, the footer and the open menu, and its ink flip
   over dark sections (.is-over-footer, main.js) is reused below.

   An opaque block in the site's own tokens, so it reads the same over the
   light panel, the red blob and the footer: --black ground with --off-white
   type on light ground, flipped over the footer. Square, like the tiles and
   the panel. Centred near the bottom of the screen, above the desktop bottom
   bar. Fades in and rises a step on the site's hover/fade timing (0.4s on
   the reveal curve); reduced motion just shows and hides it.

   It is the live region (role="status"): the text is written after it is
   shown, so the screen reader is never told about a hidden element. When
   copying fails it shows the address itself, and becomes selectable so it
   can be copied by hand.
   ------------------------------------------------------------------ */
.copy-toast {
  position: fixed;
  left: 50%;
  bottom: calc(var(--grid-margin) + var(--bottom-bar-h) + var(--gap-2));
  z-index: 1;                /* inside the header's layer: over the menu panel */
  /* Its own text width, not the half-screen that left: 50% would leave it. */
  width: max-content;
  max-width: calc(100vw - 2 * var(--page-gutter));
  padding: var(--gap-1) var(--gap-2);
  background: var(--black);
  color: var(--off-white);
  font-family: 'Swiss 721', sans-serif;
  font-weight: 500;
  font-size: var(--fs-body);
  line-height: 1.4;
  letter-spacing: var(--track);
  text-align: center;
  overflow-wrap: anywhere;   /* a long address never pushes it off a phone */
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
  transform: translate(-50%, var(--gap-1));
  transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1),
              transform 0.4s cubic-bezier(0.16, 1, 0.3, 1),
              visibility 0s linear 0.4s;
}
.copy-toast.is-shown {
  opacity: 1;
  visibility: visible;
  transform: translate(-50%, 0);
  transition: opacity 0.4s cubic-bezier(0.16, 1, 0.3, 1),
              transform 0.4s cubic-bezier(0.16, 1, 0.3, 1),
              visibility 0s linear 0s;
}
.copy-toast.is-manual {
  pointer-events: auto;
  user-select: text;
  -webkit-user-select: text;
}
.section-header.is-over-footer .copy-toast {
  background: var(--off-white);
  color: var(--black);
}
/* ...but with the menu open the light panel is behind it, whatever the page
   underneath, so it keeps the light-ground colours — as the header ink does. */
.menu-open .section-header .copy-toast {
  background: var(--black);
  color: var(--off-white);
}
/* No bottom bar below 992: it sits a section gap off the bottom instead. */
@media (max-width: 991px) {
  .copy-toast { bottom: var(--gap-5); }
}
@media (prefers-reduced-motion: reduce) {
  .copy-toast,
  .copy-toast.is-shown {
    transform: translate(-50%, 0);
    transition: none;
  }
}

html.menu-open { overflow: hidden; }
.menu-open .section-header .logo,
.menu-open .section-header .menu-toggle { color: var(--black); }
.menu-open .section-header .blob-invert { visibility: hidden; }
.menu-open .crosshair--fixed { color: var(--black) !important; }

@media (prefers-reduced-motion: reduce) {
  .menu-panel,
  .menu.is-open .menu-panel,
  .menu-toggle::before { transition: none; }
}

/* ============================================================
   Main — the light panel the footer is revealed from behind. Its
   bottom edge is square at EVERY width: the desktop 120 radius and the
   mobile full-round bottom are both gone. The wipe (SiteLine in
   blob-scene.js) reads this radius from the computed style each frame,
   so a straight edge here is a straight cut everywhere downstream.
   ============================================================ */
.site-main {
  position: relative;
  z-index: 10;
  overflow: hidden;
  border-bottom-left-radius: 0;
  border-bottom-right-radius: 0;
  /* Flat ground. The repeating grid texture that used to sit here — with a
     vertical phase derived from the hero group's centre — is gone from the
     design. assets/img/grid-tile.svg is still on disk, unreferenced. */
  background-color: var(--off-white);
  padding-bottom: var(--gap-5);
}

/* ============================================================
   THE PINNED BLOB

   Fixed to the viewport and centred, so it never moves on screen while the page
   scrolls past it. It lives inside <main>, above the light panel's own opaque
   ground and below every section, which is what lets the page's text pass in
   front of it while the footer stays hidden behind it.

   Two things here are load-bearing rather than style, both from the source file:

   NO TRANSFORM. A transform would make this box the containing block for
   fixed-position children, and the intro's full-viewport canvas is one of
   those — it would size itself to this box instead of the screen. The vertical
   offset is done with an asymmetric inset, not a translate.

   NO Z-INDEX, NO CLIP-PATH, NO FILTER, NO OPACITY, for the same reason. The cut
   at the panel's bottom edge is applied to .host by blob-scene.js.

   pointer-events: none keeps it decorative, so the text in front of it stays
   selectable. The cursor-follow tilt still works — that listener is on window.
   ============================================================ */
.blob-slot {
  /* Size. The on-screen blob comes out at about 0.69 of this height, so this
     puts it at roughly 42% of the viewport's width — comfortably wider than the
     footer cosmos and far enough to reach into the marquee on both sides.

     Capped on BOTH axes on purpose: the height cap keeps it sane on a short
     wide window, the width cap on a tall narrow one.

     The canvas bleed (140% in .host) and the camera distance (z 10.36 = 7.4
     x 1.4 in blob-scene.js) are a pair, and neither needs to move with this.
     They set the RATIO of canvas to blob, so the spike headroom stays
     proportionally identical however big this box gets. */
  --blob-h: var(--blob-size);
  position: fixed;
  /* Centred in the viewport less a little at the bottom, which lands the blob
     on the same axis the hero used to hold it on rather than on dead centre. */
  inset: 0 0 var(--blob-lift) 0;
  margin: auto;
  height: var(--blob-h);
  width: calc(var(--blob-h) * 585 / 661);
  pointer-events: none;
}

/* Browsers without dvh keep the vh they already had. */
@supports not (height: 1dvh) {
  :root {
    --blob-size: min(105vh, 61vw);
    --blob-axis: calc((100vh - var(--blob-lift)) / 2);
  }
}

/* Every section paints in front of the blob. Without this they are unpositioned
   in-flow boxes and a positioned .blob-slot would cover them. */
.section-hero,
.section-who,
.section-what {
  position: relative;
  z-index: 1;
}

/* ============================================================
   TEXT CROSSING THE BLOB

   A white copy of the text laid exactly over the original and clipped to the
   blob's traced outline, so only the letters actually on the red change colour.
   Blend modes cannot do this: difference and friends turn cyan on a red this
   saturated. Opt in per element with .over-blob — and per ELEMENT, not per
   container, because the copy is a plain div and would misalign inside a flex
   or grid parent.
   ============================================================ */
.over-blob {
  position: relative;
}

.over-blob > .blob-invert {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  padding: inherit;          /* so the copy lands on the original exactly */
  color: var(--on-blob);
  pointer-events: none;
  /* Rewritten every frame with the traced outline, already cut at the wipe
     line. Clipped to nothing by default, so if the script never runs the white
     copy simply never appears and the original text is untouched. */
  clip-path: circle(0px);
}

/* ============================================================
   HOVER UNDERLINE — the header nav and the bottom bar's contact
   links only. Not their mobile-menu duplicates (.menu-nav,
   .menu-contact): those weren't asked for and stay as they are.

   A thin bar that grows in from the left on hover or keyboard focus
   and shrinks back the same way — transform-origin stays LEFT for
   both directions, so it never grows from the middle and never
   retreats off the wrong edge.

   Colour is background: currentColor, never a token, so it can never
   drift from whatever the text itself is doing: --accent for the
   header nav, which never inverts; --black/--off-white for the
   contact links, which do (.bottom-bar.is-over-footer above).

   Every rule here is doubled — once for the real link, once for its
   .blob-invert copy where one exists (both these groups carry
   .over-blob). The copy is a second element laid exactly on top with
   pointer-events: none, so .nav a:hover is true whenever the pointer
   is anywhere in the link's box, copy included — both underlines
   answer to the same hover with nothing extra needed to keep them in
   step. The copy's underline is then cut by the SAME clip-path that
   cuts its text, so it only turns white exactly where the blob does.

   z-index: 1 on the copy is load-bearing, not decoration: ::after is
   generated as the LAST child of its host, so without it the anchor's
   OWN underline would paint on top of .blob-invert and hide the white
   one — the same risk the text itself avoids only because .blob-invert
   is a real, later DOM child. A pseudo-element doesn't get that for
   free.
   ============================================================ */
.nav a,
.bottom-bar .contact-links :is(a, button) {
  position: relative;        /* already true via .over-blob; kept explicit
                                 so this feature doesn't depend on that one */
}

.nav a .blob-invert,
.bottom-bar .contact-links :is(a, button) .blob-invert {
  z-index: 1;
}

.nav a::after,
.nav a .blob-invert::after,
.bottom-bar .contact-links :is(a, button)::after,
.bottom-bar .contact-links :is(a, button) .blob-invert::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: -0.2em;             /* close under the text, clear of descenders */
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  /* 0.4s, cubic-bezier(0.16, 1, 0.3, 1) — the site's own hover/fade curve
     (docs/DESIGN.md, Motion), also the crosshair's fade duration. */
  transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
  pointer-events: none;
}

/* Scoped to real hover capability so a tap on a touch screen can never leave
   the underline stuck showing — the same guard the crosshair cursor uses. */
@media (hover: hover) and (pointer: fine) {
  .nav a:hover::after,
  .nav a:hover .blob-invert::after,
  .bottom-bar .contact-links :is(a, button):hover::after,
  .bottom-bar .contact-links :is(a, button):hover .blob-invert::after {
    transform: scaleX(1);
  }
}

.nav a:focus-visible::after,
.nav a:focus-visible .blob-invert::after,
.bottom-bar .contact-links :is(a, button):focus-visible::after,
.bottom-bar .contact-links :is(a, button):focus-visible .blob-invert::after {
  transform: scaleX(1);
}

@media (prefers-reduced-motion: reduce) {
  .nav a::after,
  .nav a .blob-invert::after,
  .bottom-bar .contact-links :is(a, button)::after,
  .bottom-bar .contact-links :is(a, button) .blob-invert::after {
    transition: none;
  }
}

/* ============================================================
   Hero — Figma frame 70:23, y 215, height 844.
   BLOB col 5–8 (exact), Wave col 2–4, WORDS1 col 10–12 (exact),
   Contact col 9–12. Vertical offsets are the frame's own y values.
   ============================================================ */
.section-hero { min-height: 100vh; }

/* The hero is exactly one viewport tall. Blob + keywords sit on row 1,
   wave + contact links on row 2, and space-between drives them to the
   top and bottom of the screen — so nothing here falls below the fold. */
.hero-grid {
  min-height: 100vh;
  /* Row 1 absorbs all the free space so the blob and keywords can sit in the
     MIDDLE of it; row 2 stays hugged to the bottom of the viewport. Using
     space-between instead pinned row 1 to the top, which left it riding high. */
  grid-template-rows: minmax(0, 1fr);
  padding-top: 7rem;                     /* clears the fixed header */
  /* Clears the fixed bottom bar. This is the SAME total the old row 2 took, so
     the group's centre — 50vh + padTop/2 - padBottom/2 — is unchanged, and the
     background grid phase derived from it in .site-main still lands. */
  padding-bottom: calc(var(--grid-margin) + var(--bottom-bar-h));
}

.hero-grid > .hero-blob-stage,
.hero-grid > .hero-words  { grid-row: 1; align-self: center; }


/* The blob is pinned to the viewport now, so this box holds nothing. It stays
   because it is what gives the hero its height and keeps the keywords where
   they are. */
.hero-blob-stage {
  position: relative;
  width: 100%;
  aspect-ratio: 585 / 661;
  max-height: 58vh;                      /* keeps row 1 inside short viewports */
}

/* The canvas bleeds past that box on all four sides so the blob's spikes have
   somewhere to go. A WebGL canvas clips hard at its own edge, and the camera
   only ever shows a fixed vertical angle, so without the bleed the blob is
   sheared off flat wherever it reaches the frame.

   The 140%% here and the camera distance z: 10.36 in blob-scene.js are ONE
   setting in two places: the canvas grows to 1.4x the slot, so the camera must
   pull back by that same 1.4 or the blob simply renders larger. (7.4 x 1.4 =
   10.36; the header mark is the same box at 100%% and z 7.4.)

   Neither is tied to the blob's SIZE. They fix the RATIO of canvas to blob, so
   .blob-slot can be resized freely and the spike headroom stays proportionally
   the same. Measured at 1920: a 751-wide blob inside a 1405-wide canvas. */
/* ------------------------------------------------------------------
   Blob scene — these rules are VERBATIM from BLOB/blob-pinned.html and
   are the CSS contract blob-scene.js drives. Do not retune them here;
   re-extract instead.

   .host is deliberately NOT scoped to the slot — the header mark is a
   second instance and needs the same proportions.
   ------------------------------------------------------------------ */
.host {
  position: absolute;
  top: -20%;
  left: -20%;
  width: 140%;
  height: 140%;
}

.intro-backdrop {
  position: fixed;
  inset: 0;
  z-index: 60;
  pointer-events: none;
  background: radial-gradient(120% 120% at 68% 32%, #e5432a 0%, #cc0f1a 42%, #7a1410 100%);
}

.host.is-intro {
  position: fixed;
  top: 0;
  left: 0;
  right: auto;
  bottom: auto;
  width: 100%;
  height: 100%;
  transform-origin: 0 0;
  pointer-events: none;
  z-index: 50;
}

.host-ghost {
  visibility: hidden;
  pointer-events: none;
}

.host.is-docked {
  position: fixed;
  top: 0;
  left: 0;
  right: auto;
  bottom: auto;
  transform-origin: 0 0;
  z-index: 50;
  will-change: transform;
}

/* No top offset any more: that 98px reproduced the frame's y-offset back when
   the blob and keywords were both top-aligned. Now that row 1 centres them, the
   margin only skews the keywords ~41px below the blob's axis. Co-centred is
   also what the frame itself is close to (its 98 vs 123 for dead-centre). */

/* ------------------------------------------------------------------
   Crosshair cursor — VERBATIM from CURSOR/crosshair.html, which marks
   these rules as "copy these along with the script". The component in
   main.js builds the elements and drives them; nothing here is tuned.

   .crosshair-host is what hides the native cursor. It lands on <body>,
   and `cursor` inherits — but the UA stylesheet still gives links their
   own pointer, so affordance on the nav and contact links survives.
   ------------------------------------------------------------------ */
.crosshair {
  pointer-events: none;
  z-index: 10000;
}
.crosshair--fixed  { position: fixed;    inset: 0; }
.crosshair--inside { position: absolute; inset: 0; }

.crosshair__line {
  position: absolute;
  top: 0;
  left: 0;
  background: currentColor;
  opacity: 0;
  will-change: transform, opacity;
}
.crosshair__line--h { width: 100%; height: var(--crosshair-thickness, 1px); }
.crosshair__line--v { height: 100%; width:  var(--crosshair-thickness, 1px); }

.crosshair-host { cursor: none; }

/* SITE ADDITION (not from the source file): the rule above only sets `cursor`
   on <body>, and `cursor` inherits — but the UA stylesheet gives <a> its own
   `pointer`, and text its own `text` caret, and those beat an inherited value.
   The result was the Windows pointer reappearing over every link. With a fully
   custom cursor there is nothing for the native one to add, so it goes on all
   descendants. */
.crosshair-host,
.crosshair-host * { cursor: none; }

/* ------------------------------------------------------------------
   Fixed bottom bar — the mirror of the fixed header.

   Not in the hero's flow: fixed to the viewport for the whole page, so
   it is still there over the project grid and the footer. It carries
   the wave and the contact links for the entire site, which is why the
   footer no longer repeats them.
   ------------------------------------------------------------------ */
.bottom-bar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: var(--grid-margin);
  z-index: 20;
  background: transparent;
  /* The bar spans the full width but is mostly empty. Without this it would
     swallow clicks and drags meant for the page underneath it — including the
     blob, which is draggable. */
  pointer-events: none;
}

.bottom-bar .u-grid { align-items: end; }

.bottom-bar .contact-links :is(a, button) { pointer-events: auto; }

/* Over the inverted footer the bar stays transparent; only the ink flips,
   exactly as the header does. */
.bottom-bar.is-over-footer .hero-wave { color: var(--off-white); }

.bottom-bar.is-over-footer .contact-links :is(a, button) {
  color: var(--off-white);
  border-color: var(--off-white);
}

.hero-wave {
  width: 11.8182rem;                     /* 260 */
  max-width: 100%;
  color: var(--black);
}

/* ============================================================
   Who I am — Figma frame 70:24, y 1147, height 928.
   Copy stack col 2–7 (body 891 wide is exactly col 2→7),
   portrait col 10–12.
   ============================================================ */
.section-who { min-height: 42.182rem; }  /* 928 */

/* Everything in the section sits on grid row 1 and is offset with margin-top,
   which is how the frame itself is built. The portrait keeps the frame's y
   (131, minus the 83 the padding already provides). */
.who-grid { padding-top: var(--who-top); }

.profile-pic  { margin-top: 2.1818rem; }               /* 131 − 83 */

/* DESKTOP ONLY — below 992px the stacked layout sets its own margins and none
   of this applies.

   The heading sits at --heading-top, the one distance "WHAT I DO" uses too:
   this grid's padding is shorter (it places the portrait), so the heading
   makes up the difference with its margin.

   The statement and the body copy are measured with the section scrolled to
   the top of the screen — where the "Who I am" link lands it — and each is
   centred on the blob's own centre line (--blob-axis), on its OWN middle, so
   the two blocks sit level about that line whatever their heights. translate
   percentages are of the element's own height, which is what lets CSS centre
   a block it can't measure. It is a visual move only: the layout box stays at
   the top of the row, so the section's height is still set by the portrait
   and nothing below it shifts. The white copies re-read the rendered position
   every frame, so they follow the translate. */
@media (min-width: 992px) {
  .who-heading { margin-top: calc(var(--heading-top) - var(--who-top)); }

  .who-body,
  .who-statement {
    margin-top: 0;
    transform: translateY(calc(var(--blob-axis) - var(--who-top) - 50%));
  }
}

.who-statement {
  max-width: 100%;
  font-family: 'Swiss 721 Extended', sans-serif;
  font-weight: 400;
  font-size: var(--fs-statement);
  line-height: 1.125;                                  /* 45 / 40 */
  letter-spacing: var(--track);
}

/* Cut-out PNG: transparent corners and top, subject opaque to the
   bottom edge. No background box, and never cropped — a fixed
   aspect-ratio + cover would clip his head or feet.
   Desaturated, which is how the frame shows it. */
.profile-pic {
  display: block;
  width: 100%;
  height: auto;
  filter: grayscale(1);
}

/* ============================================================
   What I do — Figma frame 19:58, y 2163, height 973, rounded
   bottom 120. Cards col 2–8 (1042 wide = exactly col 2→8),
   meta col 10–12.
   ============================================================ */
.section-what { min-height: 44.227rem; } /* 973 */

.what-grid { padding-top: var(--heading-top); }

/* DESKTOP ONLY — below 992px the stacked layout sets its own margin.

   The "What I do" link lands this section at the top of the screen, and what
   has to be on screen then is the header, the heading, and all three tiles
   with their names — in that order, nothing cut off. So the grid no longer
   sits at the frame's y (209 below the heading row); it follows the heading
   on its own row, a --gap-4 below it.

   max-height is the grid's height BUDGET at that landing: the screen, less
   the heading's place and gap above, less the same bottom-bar reserve the hero
   and footer already keep clear (--grid-margin + --bottom-bar-h). main.js
   reads the resolved value and scales the tiles down just enough to fit it —
   and not at all when the screen already has room. */
@media (min-width: 992px) {
  .what-grid > .masonry {
    grid-row: 2;
    margin-top: var(--gap-4);
    max-height: calc(100dvh - var(--heading-top) - var(--heading-h) - var(--gap-4)
                     - var(--grid-margin) - var(--bottom-bar-h));
  }
}

/* ------------------------------------------------------------------
   Project grid — ported from PROJECTS/3 (Masonry.css). main.js packs
   the tiles and owns their position, size and animation; these rules
   only describe how a tile looks.
   ------------------------------------------------------------------ */
/* 114 at the design width: 22 padding + a 35 name line + a 35 tags line + 22.
   The media and the tile's hairline are both cut short by exactly this.

   --m-scale is the fit factor main.js writes when the tiles have to shrink to
   fit (1 otherwise). The label band, its padding and its type all multiply by
   it, so the name and tags shrink WITH the picture, and the band and the image
   box keep reading the same --m-label-h — they still meet on one edge. */
.masonry { --m-label-h: calc(5.1818rem * var(--m-scale, 1)); }

.masonry {
  position: relative;
  width: 100%;
}

.masonry-item {
  position: absolute;
  top: 0;
  left: 0;
  will-change: transform, width, height;
}

/* the frame owns the reveal transform. It paints nothing itself: the ground
   and the hairline belong to the image box, and the label below it sits
   straight on the page.

   Square corners: the frame draws the tiles with cornerRadius 0 now. main.js
   still writes --m-radius from CFG.radius, but nothing reads it — the radius
   is set here instead, so the CFG object stayed untouched. */
.masonry-frame {
  position: relative;
  width: 100%;
  height: 100%;
  overflow: hidden;
  border-radius: 0;
  will-change: transform, opacity, filter;
}

/* The image area: everything above the label band. This is what clips the
   media — both its parallax overscan and its hover zoom — so the label no
   longer has to be an opaque lid to stop the picture leaking into it. It
   carries the tile's ground and its hairline, which belong to the picture
   rather than to the caption under it. */
.masonry-image {
  position: absolute;
  inset: 0 0 var(--m-label-h) 0;
  overflow: hidden;
  background: var(--off-white);
  box-shadow: inset 0 0 0 1px rgba(33, 31, 31, 0.1);
}

/* the media layer is taller than its box so it can drift without gapping.
   .masonry-image clips it. */
.masonry-media {
  position: absolute;
  left: 0;
  right: 0;
  top: calc(-1 * var(--m-overscan, 0px));
  bottom: calc(-1 * var(--m-overscan, 0px));
  background-color: rgba(33, 31, 31, 0.06);
  background-size: cover;
  background-position: center center;
  background-repeat: no-repeat;
  will-change: transform;
}

/* Project name and tags sit BELOW the image now, on the page's own ground —
   no scrim, dark ink. 22 of padding top and bottom and none at the sides,
   which is what puts the name flush with the tile's left edge. */
.masonry-label {
  position: absolute;
  inset: auto 0 0 0;
  z-index: 1;
  height: var(--m-label-h);
  padding: calc(1rem * var(--m-scale, 1)) 0;   /* 22 */
  pointer-events: none;
  /* No background. It sits straight on the page ground, so when the blob
     passes behind the tile the band does not paint over it — only the name and
     the tags invert, and only where the blob actually is. The media is kept
     out of here by .masonry-image clipping it, not by this covering it. */
  color: var(--black);
}

.masonry-label .project-name,
.masonry-label .project-tags { color: var(--black); }

/* --line-fit: 1 unless the line would be wider than its tile, then main.js
   sets the factor that fits it on one line (see fitNames). nowrap, so a long
   name or tag can never wrap and push the label out of its band. */
.masonry-label .project-name {
  font-size: calc(var(--fs-project) * var(--m-scale, 1) * var(--line-fit, 1));
  white-space: nowrap;
}
.masonry-label .project-tags {
  font-size: calc(var(--fs-body) * var(--m-scale, 1) * var(--line-fit, 1));
  white-space: nowrap;
}

.masonry-link {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: block;
  border-radius: inherit;
  text-decoration: none;
}

.masonry-link:focus-visible {
  outline: 2px solid var(--black);
  outline-offset: 3px;
}

/* Fallback when GSAP is unavailable — tiles are absolutely positioned
   by script, so without it they would stack on top of each other. */
.masonry.is-static {
  display: flex;
  flex-direction: column;
  gap: var(--gap-2);
  height: auto !important;
}

.masonry.is-static .masonry-item {
  position: relative;
  width: 100% !important;
  height: auto !important;
  aspect-ratio: 16 / 9;
}

@media (prefers-reduced-motion: reduce) {
  .masonry-frame,
  .masonry-media,
  .masonry-item { will-change: auto; }
}

.project-name {
  font-family: 'Swiss 721 Extended', sans-serif;
  font-weight: 700;
  font-size: var(--fs-project);
  line-height: 1.094;         /* 35 / 32 */
  letter-spacing: var(--track);
}

.project-tags {
  font-family: 'Swiss 721 Extended', sans-serif;
  font-weight: 400;
  font-size: var(--fs-body);
  line-height: 1.458;
  letter-spacing: var(--track);
}

/* ============================================================
   Marquee — Figma 118:13. A single line of type, wider than the
   page, tracking off the bottom of "What I do".

   The track holds the line twice and slides exactly one copy's
   width, so the second is sitting where the first began when it
   wraps and the loop is seamless.

   IT IS MOVED FROM JAVASCRIPT, not by a CSS animation, and that is
   load-bearing rather than a preference. A CSS transform animation
   is handed to the compositor thread, where it runs ahead of what
   the main thread believes the position to be — and the white
   inverted copy inside the marquee is clipped using exactly that
   believed position. The clip lives in the element's own moving
   coordinate system, so a stale reading put the white text a few
   hundred pixels to the right of the blob. Stepping the track on
   the same frame that measures it removes the gap entirely.
   See the marquee module in main.js.
   ============================================================ */
.ticker {
  overflow: hidden;
  width: 100%;
  margin-top: 12.2273rem;   /* 269 — the frame's gap under the grid */
  user-select: none;
}

.ticker-track {
  display: flex;
  width: max-content;
}

.ticker-run {
  font-family: 'Swiss 721 Extended', sans-serif;
  font-weight: 400;
  font-size: var(--fs-display);
  line-height: 1.2;
  letter-spacing: var(--track-display);
  color: var(--black);
  white-space: nowrap;
}

/* Reduced motion is handled where the movement now lives, in main.js: the
   track simply never steps and the line sits still. */

/* ============================================================
   Sticky full-viewport reveal footer — Figma frame 19:47.
   Cosmos col 5–8 (exact, same span as the hero BLOB), Wave
   col 2–4, WORDS2 col 10–12 (exact), Contact col 9–12.

   `bottom: 0`, not `top: 0` — with the footer as last child and
   nothing below it, `top: 0` gives no hold at all; its natural
   position only reaches the viewport top at maximum scroll.
   ============================================================ */
.section-footer {
  position: sticky;
  bottom: 0;
  z-index: 0;
  height: 100vh;
  background: var(--black);
  color: var(--off-white);
}

/* The cosmos fills the footer, behind its content. The light panel above it is
   opaque, so as the panel scrolls up its bottom edge wipes between the blob and
   this. Decorative: the footer's own links stay clickable, and the site's
   crosshair is not interrupted. */
.cosmos-layer {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;
}

.cosmos-layer canvas {
  display: block;
  width: 100%;
  height: 100%;
}

.section-footer > .u-container {
  position: relative;
  z-index: 1;
  height: 100%;
}

/* The footer must fill exactly 100vh for the reveal to hold, while
   Figma's frame is 1200 tall. Fixed rem offsets would overflow or
   bunch depending on viewport height, so the frame's two content
   bands (Cosmos+WORDS2 upper, Wave+Contact lower) are distributed
   with space-between instead. Column placement is unchanged. */
.footer-wrapper {
  height: 100%;
  display: flex;
  flex-direction: column;
  /* One band now, so centre it. `space-between` was correct with two bands but
     with one it just pins the band to the top, leaving it riding high. */
  justify-content: center;
  /* Top clears the fixed header, bottom clears the fixed bottom bar — the same
     reservation the hero makes, so the band centres in what is actually visible. */
  padding: 9.773rem 0 calc(var(--grid-margin) + var(--bottom-bar-h));
}

/* Co-centred, so the cosmos and the keywords share one axis. `start` left them
   30.8px apart, since the two are different heights. */
.footer-band { align-items: center; }

/* Figma 122:2 — the closing line, on the same band as the cosmos and the
   keywords, in the footer's light ink. */
.footer-line {
  font-family: 'Swiss 721 Extended', sans-serif;
  font-weight: 400;
  font-size: var(--fs-display);
  line-height: 1.2;
  letter-spacing: var(--track-display);
  color: var(--off-white);
}
.footer-cosmos {
  width: 100%;
  aspect-ratio: 586 / 581;
  max-height: 50vh;
  border: 0;
  display: block;
  /* Empty. The cosmos moved to .cosmos-layer behind the whole footer; this box
     stays so the band keeps the height it had, which is what holds the closing
     line and the keywords on their existing axis. */
  visibility: hidden;
}

/* No top offset: that 25px reproduced the frame's y-offset back when the band
   was top-aligned. Against centring it only pushes the keywords off the axis —
   the same correction the hero keywords needed. */

.section-footer .words li { color: var(--off-white); }
.section-footer .contact-links :is(a, button) { color: var(--off-white); }

/* ============================================================
   Sine wave — built into [data-sine-wave] by main.js, strokes
   with currentColor so the container's `color` picks the ink.
   ============================================================ */
[data-sine-wave] svg { display: block; width: 100%; height: auto; }

/* ============================================================
   PROJECT PAGE — Figma frame 147:2 (project.html).

   Same shell as the homepage: fixed header, .site-main panel, the
   12-column grid. There is no blob, no footer and no bottom bar on this
   page — the frame draws none — so the blob scene is kept only because the
   header mark is one of its instances. See design.md deviation 31.

   Structure, top to bottom:
     .project-hero      the photo, pinned; the panel slides up over it
     .project-panel     cream description panel
     .section-carousel  dark ground, the stack carousel
   ============================================================ */

/* The hero is sticky INSIDE .site-main, and overflow: hidden on an ancestor
   would stop it sticking. The blob is the only reason main clips, and there is
   no visible blob here. */
.page-project .site-main {
  overflow: visible;
  padding-bottom: 0;
}

/* The header mark is a blob-scene.js instance, and that file will not start
   without a pinned blob to hang the scene off. The page has none in its
   design, so the slot is kept at 2px and invisible, and the red intro cover
   is never shown. The file itself stays verbatim. */
.page-project .blob-slot {
  width: 2px;
  height: 2px;
  visibility: hidden;
}
.page-project .intro-backdrop { display: none; }
/* The white text copies are clipped to the blob, which is invisible here. During
   the opening its full-screen canvas still counts, so they showed over the
   header for about two seconds. There is never a blob for them to sit on. */
.page-project .blob-invert { display: none; }

.project-hero {
  position: relative;
  z-index: 0;
  height: 60vh;
  height: 60svh;
  overflow: hidden;
}

/* The frame crops the photo in tight (2561 wide in a 1921 frame). Cover plus a
   scale reproduces that at any viewport ratio without ever leaving a gap. */
.project-hero img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 50% 40%;
  transform: scale(1.25);
  transform-origin: 40% 45%;
}

/* On a portrait screen the zoom would crop the laptop away. */
@media (max-width: 991px) {
  .project-hero img { transform: none; object-position: 38% 50%; }
}

.project-panel {
  position: relative;
  z-index: 1;
  min-height: 100vh;
  min-height: 100dvh;
  padding: 3.5909rem 0;              /* 79 */
  background: var(--off-white);
  border-radius: inherit;            /* follows the panel's corners — square at every width now */
}

.project-heading {
  font-family: 'Swiss 721 Extended', sans-serif;
  font-weight: 400;
  font-size: var(--fs-section);
  line-height: 1.2;                  /* 60 / 50 */
  color: var(--black);
}

/* The frame leaves the copy at "normal" leading — 29 against 24 — where the
   homepage body copy carries an explicit 35. Desktop and tablet follow the
   frame; the mobile step keeps the site's own 30/16. */
@media (min-width: 768px) {
  .project-panel .body-text { line-height: 1.2083; }   /* 29 / 24 */
}

.project-head {
  display: flex;
  flex-direction: column;
  gap: var(--grid-gutter);           /* 32 */
}

.project-title-block {
  display: flex;
  flex-direction: column;
  gap: 1.5909rem;                    /* 35 */
}

/* The title row: the title on the left and, on a page that has one, the
   "Check out the Prototype" line on the right, on the title's baseline. The row
   is as high as the title alone, so nothing below it moves. Its right edge is
   column 11's, the same edge the text blocks end on. */
.project-title-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  column-gap: var(--grid-gutter);
  row-gap: var(--gap-2);
}

/* Set like the nav: Swiss 721 Medium at --fs-nav, the standard tracking. The
   words sit a --gap-2 apart; "Prototype" is the link, in --accent. */
.project-prototype {
  display: inline-flex;
  gap: var(--gap-2);
  font-family: 'Swiss 721', sans-serif;
  font-weight: 500;                  /* Medium */
  font-size: var(--fs-nav);
  line-height: 1.2;
  letter-spacing: var(--track);
  color: var(--black);
  white-space: nowrap;
}

.project-prototype a {
  position: relative;
  color: var(--accent);
  text-decoration: none;
}

/* The same underline as the header nav (see HOVER UNDERLINE): it grows in from
   the left on hover or keyboard focus and shrinks back the same way, in
   currentColor, only where there is a real pointer. */
.project-prototype a::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: -0.2em;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.4s cubic-bezier(0.16, 1, 0.3, 1);
  pointer-events: none;
}

@media (hover: hover) and (pointer: fine) {
  .project-prototype a:hover::after { transform: scaleX(1); }
}

.project-prototype a:focus-visible::after { transform: scaleX(1); }

@media (prefers-reduced-motion: reduce) {
  .project-prototype a::after { transition: none; }
}

/* On a phone the page is centred, and the line drops under the title. */
@media (max-width: 767px) {
  .project-title-row { justify-content: center; }
}

.project-meta { display: flex; }
.project-meta p:first-child { min-width: 6.9545rem; }   /* 153 */

.project-body {
  display: flex;
  flex-direction: column;
  gap: 1.5rem;                       /* 33 */
}

.project-task,
.project-problem,
.project-outcome {
  display: flex;
  flex-direction: column;
}
.project-task { gap: 1.2273rem; }                       /* 27 */
.project-problem,
.project-outcome { gap: 1.3182rem; }                    /* 29 */

/* A fourth band, for a project that has one more section than the template
   (Approbo's "Building the prototype"). Same heading, same gaps as Task; its
   paragraphs sit 33 apart like the lead and the tags above. */
.project-build {
  display: flex;
  flex-direction: column;
  gap: 1.2273rem;                    /* 27 */
}
.project-build-text {
  display: flex;
  flex-direction: column;
  gap: 1.5rem;                       /* 33 */
}

.project-list {
  list-style: disc;
  padding-left: 1.6364rem;           /* 36 */
}

/* DESKTOP ONLY — every child of a .u-grid sits on row 1, so the three bands
   are given their own rows here. Below 992px the grid is one column and the
   row-gap sets the rhythm instead. */
@media (min-width: 992px) {
  /* Pinned for the whole panel: it scrolls up over the photo, the way the
     footer is uncovered on the homepage. Full screen, dvh with the vh fallback. */
  .project-hero {
    position: sticky;
    top: 0;
    height: 100vh;
    height: 100dvh;
  }

  /* The page opens with the panel already over the photo: its top edge sits at
     42.6% of the screen, which is where the reference screenshot has it (the
     frame's 454 of 1080). Pulled up by the rest of the hero's height. */
  .project-panel {
    margin-top: -57.4vh;
    margin-top: -57.4dvh;
  }

  .project-grid > .project-head    { grid-row: 1; }
  .project-grid > .project-task    { grid-row: 2; margin-top: 6.318rem; }     /* 139 */
  .project-grid > .project-problem,
  .project-grid > .project-outcome { grid-row: 3; margin-top: 2.5909rem; }    /* 57 */
  .project-grid > .project-build   { grid-row: 4; margin-top: 2.5909rem; }    /* 57 */
}

/* ------------------------------------------------------------------
   Stack carousel — VERBATIM from PROJECTS/P_ANIMATIONS/stack-carousel.html,
   with the adaptations marked below. Re-apply them if the file is re-copied.
   The markup and the script (main.js) are the source's own, likewise adapted.
   ------------------------------------------------------------------ */
.section-carousel {
  background: var(--black);
  color: var(--off-white);
  padding: 8.6818rem 0 7.4545rem;    /* 191 above, 164 below */
}

/* SITE ADAPTATION — THE CAROUSEL FITS THE WINDOW.
   Whatever is on top is shown whole, with the header above it and nothing
   past the bottom of the window, at any window size. The frame is one fixed
   shape (16:10) on every slide; the picture inside is never cropped (see
   object-fit below). Sized from the window's height as well as its width.

   --stack-head   where the header ends. main.js writes the real, measured
                  value (the header changes size at 991px); 7rem is only the
                  fallback if the script has not run.
   --stack-gap    breathing room under the header and above the window's edge.
   --stack-frame  the tallest frame that fits. The window's height, less the
                  header and the two gaps, shared with the 7% a covered card
                  lifts when the next one slides over it (RECEDE_LIFT in
                  main.js), so even that peeking edge stays clear of the
                  header. It is 1.07 frames of height: 1 for the card, 0.07
                  for the peek.
   --stack-unit   1dvh, or 1vh where dvh is not supported (docs/DESIGN.md,
                  Viewport units). A custom property cannot carry a fallback
                  for an unsupported unit, so the unit itself is swapped.   */
.stack-track {
  position: relative;
  --stack-unit: 1vh;
  --stack-head: 7rem;
  --stack-gap: 1rem;
  --stack-frame: calc((100 * var(--stack-unit) - var(--stack-head) - 2 * var(--stack-gap)) / 1.07);
}
@supports (height: 1dvh) {
  .stack-track { --stack-unit: 1dvh; }
}

.stack-stage {
  position: sticky; top: 0;
  height: 100vh; height: 100dvh;      /* source: 100vh / 100svh */
  overflow: hidden;
  display: grid; place-items: center;
  /* The band the frame sits in: under the header and the peek, above the gap. */
  grid-template-rows: calc(var(--stack-head) + var(--stack-gap) + var(--stack-frame) * .07) 1fr var(--stack-gap);
}
.stack-card {
  position: absolute; margin: 0;
  grid-row: 2 / 3;                   /* both lines: an open end would run to the bottom edge */
  /* The source caps the card at 1100px and 76svh * 1.5. The frame draws it
     1509 wide, which is columns 2-11 of the grid, so the width cap is that
     span (.stack-stage is the width of the container's content), and the
     other cap is the frame height that fits the window, at 16:10. */
  width: min(calc((10 * 100% - 2 * var(--grid-gutter)) / 12), calc(var(--stack-frame) * 1.6));
  aspect-ratio: 16 / 10; max-width: 100%;
  overflow: hidden;
  /* The bars around a picture that is not 16:10. Opaque, because the cards
     stack and a see-through bar would show the card underneath. */
  background: var(--off-white);
  transform-origin: 50% 0%;
  will-change: transform;
  box-shadow: 0 -18px 50px -20px rgba(0,0,0,.35);
}
/* SITE ADAPTATION: the source styles images only, and crops them (cover). The
   videos take the same rule, and nothing is cropped: contain shows each
   picture whole inside the frame. */
.stack-card img, .stack-card video { width: 100%; height: 100%; object-fit: contain; display: block; user-select: none; -webkit-user-drag: none; }
.stack-dim { position: absolute; inset: 0; background: #000; opacity: 0; pointer-events: none; }

/* Below 992px there are no columns, so the frame takes the full width. */
@media (max-width: 991px) {
  .stack-card { width: min(100%, calc(var(--stack-frame) * 1.6)); }
}

/* ============================================================
   Short viewports — the footer is pinned at exactly 100vh, so its
   content has to fit the viewport's HEIGHT, not just its width.
   Five 50px words plus the cosmos overflow a ~700px window, and
   `overflow: hidden` would clip them. Scoped to the footer so the
   hero keeps the design's type size.
   ============================================================ */
@media (max-height: 820px) {
  .footer-wrapper { padding-top: 6.5rem; }
  .footer-cosmos { max-height: 34vh; }
  /* The keyword lists used to be stepped down here as well. They are not any
     more, and they must not be: a laptop viewport is almost always under 820
     tall, so this fired nearly all the time on a laptop and dropped the type to
     5.0% of the blob's width against 6.7% at 1920 — small type with tight
     leading, next to a blob that had kept scaling. It read as a mistake.

     Nothing is needed in its place. --fs-keyword is derived from --blob-size,
     whose height term shrinks on a short viewport exactly as the blob does, so
     the lists already give way on their own and stay in proportion doing it. */
}

/* ============================================================
   Responsive — the 12-column grid collapses to one column at
   991px. Figma defines a single 1920 frame, so everything above
   991px is handled by the fluid root scaling the whole grid.
   ============================================================ */
@media (max-width: 991px) {
  :root {
    --grid-margin: 1.25rem;
    --grid-gutter: 1rem;
  }

  .u-grid { grid-template-columns: 1fr; row-gap: var(--gap-4); }

  .u-grid > * {
    grid-row: auto;
    grid-column: 1 / -1;
    margin-top: 0;
  }

  .hero-grid {
    min-height: 0;
    grid-template-rows: none;
    align-content: start;
    padding-top: 7rem;
    padding-bottom: 0;
  }

  .hero-grid > .hero-blob-stage {
    grid-row: auto;
    align-self: start;
  }

  /* No keyword lists on tablet or mobile — requested directly. display: none
     rather than visibility: hidden, so they leave the layout too: the hero
     closes up under the blob and the footer band re-centres on the closing
     line and the cosmos. It also takes them out of the accessibility tree,
     which is right for words that are decoration on a phone.

     Nothing else needs to know. The reveal finds them by [data-reveal] and a
     hidden list simply never registers as on screen; their white copies over
     the blob measure an empty box and stay clipped to nothing. --fs-keyword
     and the lists' own rules stay as they are for desktop. */
  .hero-words,
  .footer-words { display: none; }

  /* The header becomes the frame's single row: blob mark, logotype, circle.
     The 12 columns no longer apply here; three do.

     The two outer tracks are EQUAL flexible shares, so the logotype in the
     middle (its own width, `auto`) lands on the exact centre of the screen
     however different the mark and the circle are — the mark is 52 wide
     here and 31 below 768, the circle 29. The mark pins to the left edge of
     its track and the circle to the right, so each sits on the page margin
     (.u-container's --page-gutter) and the space between them is what grows
     and shrinks with the screen.

     minmax(0, 1fr) rather than 1fr, so the two tracks stay equal even if one
     side's content were ever wider than its share — a plain 1fr would grow to
     fit it and push the logotype off centre. The gutter keeps the logotype
     clear of both. Nothing here has a pixel width and nothing can shrink:
     the mark and the circle are rem-sized, the logotype is nowrap. */
  .header-grid {
    grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
    column-gap: var(--grid-gutter);
    row-gap: 0;
  }
  .header-grid > * { grid-row: 1; }
  .header-grid > .logo-blob { grid-column: 1; justify-self: start; }
  .header-grid > .logo      { grid-column: 2; padding-left: 0; }
  .header-grid > .menu      { grid-column: 3; justify-self: end; }

  /* The header nav and the whole fixed bottom bar come off: their links live
     in .menu at these widths, and the sine wave — which only ever existed
     inside the bar — does not exist on mobile at all. */
  .nav { display: none; }
  .bottom-bar { display: none; }
  .menu { display: block; }

  .section-who, .section-what { min-height: 0; }

  /* The hero is exactly one screen here. With the keyword lists hidden its
     content is only the empty blob stage, and left to that height the "Who I
     am" section rode up into the opening screen, its heading landing on the
     pinned blob. One full screen keeps the opening view to the blob alone and
     starts the next section just below the fold, at any phone or tablet
     height. dvh, with the vh line kept for browsers that lack it. */
  .section-hero { min-height: 100vh; min-height: 100dvh; }

  .hero-blob-stage { max-width: 22rem; }

  /* THE BLOB MATCHES THE COSMOS here (991 and below, phones and tablets alike).
     The cosmos frames its tangle off the footer's SHORTER side (CONFIG.fill in
     cosmos-scene.js), so the blob is sized off the same thing, and the two read
     as one object at the wipe. 0.8 was measured AT the wipe, not derived:
     with the line on the blob's centre — the moment the cosmos starts and the
     handover happens — both canvases were read in the same frames, and their
     rendered width-and-height averages meet at 0.8: blob over cosmos has a
     median of 0.94-1.01 across runs at 320-991 (the tangle breathes, so no
     constant pins it exactly). It replaces the fixed 22rem (tablet) and 185 (the iPhone
     frame) the blob used to have, which left it about half the cosmos's size.

     Only the slot changes. The canvas bleed (.host 140%) and the camera z
     stay as they are — they set the canvas-to-blob ratio, not the size — and
     the white text copies follow the traced outline at whatever size it is. */
  .blob-slot {
    --blob-w: calc(min(100vw, 100vh) * 0.8);
    --blob-w: calc(min(100vw, 100dvh) * 0.8);
    width: var(--blob-w);
    height: calc(var(--blob-w) * 661 / 585);
  }

  /* ...and the cosmos centres on the blob's own axis. The blob sits a
     --blob-lift above the viewport's middle (see .blob-slot); the cosmos layer
     fills the footer, which at the wipe IS the viewport, so its centre was half
     a lift lower. Shifted up by exactly that, at the same size, so its scale is
     untouched; the strip it leaves at the bottom is the footer's own --black,
     the same as the canvas ground. */
  .cosmos-layer {
    inset: calc(var(--blob-lift) / -2) 0 calc(var(--blob-lift) / 2) 0;
  }
  .hero-wave { width: 12rem; }
  /* Centred on its own axis at every width up to 991, phones and tablets
     alike — the same axis the pinned blob sits on. */
  .profile-pic { max-width: 20rem; justify-self: center; }

  .who-heading { margin-top: 0; }
  .profile-pic,
  .who-body,
  .who-statement { margin-top: var(--gap-3); }

  .masonry { margin-top: var(--gap-3); }

  /* THE FOOTER on tablet and mobile: the cosmos fills it (.cosmos-layer),
     and the closing line — the only content left now the keyword list is
     hidden — sits centred at the BOTTOM of the screen. The empty spacer box
     (.footer-cosmos) existed only to hold the desktop band's height, so it
     goes; the wrapper packs what is left to its end. The bottom padding is
     --gap-3, the same 2rem the header keeps from the TOP edge (1rem offset +
     1rem padding), so the page is framed evenly top and bottom.

     dvh, not vh, here: the line now sits at the very bottom of a 100vh box,
     and on iOS Safari 100vh runs under the bottom toolbar, which would cover
     it. The vh line stays for browsers without dvh. On every desktop browser
     the two are equal. */
  .section-footer { height: 100vh; height: 100dvh; }
  .footer-wrapper {
    justify-content: flex-end;
    padding: 10rem 0 var(--gap-3); /* top clears the header */
  }
  .footer-cosmos { display: none; }
  /* .footer-words had a margin-top: 0 here. It was redundant — .u-grid > *
     already zeroes it — and it was one more footer-only spacing rule on a
     component that is supposed to resolve the same on both grounds. */
  .contact-links { flex-wrap: wrap; gap: var(--gap-2); }

  /* ALIGNMENT, tablet and mobile alike (991px and below). The iPhone frame
     centres every page-level text block: body copy and the pull statement are
     CENTER, and the two display headings sit on the frame's centre axis. (It
     centres the keyword lists too, but they are hidden at these widths.) The
     footer's closing line is centred as well, on request. Tablet is the same
     single column, so it centres the same way. These rules used to sit in the 767px block; they moved here
     whole rather than being copied, so there is one set for both.

     Text INSIDE a project tile stays left against the tile's own padding, so
     this is scoped to the elements rather than to a container. */
  .display-outline,
  .project-heading,
  .body-text,
  .who-statement,
  .footer-line { text-align: center; }

  /* Bullets read badly centred, so the project lists stay left. */
  .project-list { text-align: left; }
}

/* ============================================================
   Mobile type and geometry — read off the Figma frame
   "iPhone 16 - 1" (393 x 852). Text alignment is shared with
   tablet and lives in the 991px block above.

   The root is LOCKED at 16px below 1440px, so the conversion
   here is  rem = Figma px / 16  — not the desktop frame's / 22.
   Getting that backwards makes every value 37% wrong.
   ============================================================ */
@media (max-width: 767px) {
  :root {
    --fs-display: 2.1875rem;   /* 35 — the desktop heading moved 80 -> 70, so
                                  the mobile one moves by the same 0.875 */
    --fs-keyword: 1.75rem;     /* 28 */
    --fs-statement: 2rem;      /* 32 */
    --fs-section: 2rem;        /* 32 — no frame value; matches the statement */
    --fs-body: 1rem;           /* 16 */
    --fs-project: 1.5rem;      /* 24 */
    --fs-logo: 1.03125rem;     /* 16.5 — the "Menu" frame's logotype, solved
                                  from its ink width against the real face. It
                                  was 18 (the older iPhone frame); the newer
                                  frame is followed, and the WHO I AM / WHAT I
                                  DO headings, which match the logotype, follow
                                  it down. */
    /* The header's side margin on phones, off the "Menu" frame: the mark's
       visible left edge at 37 and the circle's right edge 34 from the screen
       edge at 393. 2.25rem splits the 1-2px between them. It is the header's
       own: the page content keeps --page-gutter. */
    --header-gutter: 2.25rem;  /* 36 */
    --fs-nav: 1.5rem;          /* 24 — same rule */
    --track-display: 0.15625rem; /* 2.5 — the desktop 5 at half the heading size */
    --track: 0.034375rem;      /* 0.55 — the frame tracks the same 0.55px it
                                  does at 1920, which 0.025rem only produces
                                  at a 22px root. */

    /* An EXPLICIT page margin, not the grid-derived calc(). The background
       grid is a desktop construct and its formula is already sitting on its
       1rem floor by this width. 2rem puts the content column at 329 on a 393
       screen; the frame splits the difference between 328 (project grid) and
       333 (body copy). */
    --page-gutter: 2rem;       /* 32 */
  }

  /* Line heights are the frame's own, not the desktop ratios carried down. */
  .body-text     { line-height: 1.875; }    /* 30 / 16 */
  .who-statement { line-height: 1.40625; }  /* 45 / 32 */
  .project-name  { line-height: 1.458; }    /* 35 / 24 */

  /* The gap is 0.58 of the keyword size everywhere now, so there is nothing to
     set here: against
     the 1.75rem mobile keyword it lands on 16.2, which is the 16 this used to
     hard-code. The frame draws its two keyword boxes at different steps — 63 in
     the footer, 49.75 in the hero — but they are one component, and reading as
     two rhythms was worse than matching the frame. */

  /* Alignment lives in the 991px block above: tablet and mobile share one
     set of centring rules. */

  /* --- Geometry, all of it read off the frame --------------------- */

  /* The header mark, off the "Menu" frame: its VISIBLE blob is 29 across —
     the circle's size — where the old 31x30 box rendered only 21 of it (the
     canvas bleeds 140% and the camera frames the blob inside that). So the
     box is scaled by 29/21, and pulled left by half the clear space around
     the blob, so the blob itself, not its box, lands on the header margin. */
  .logo-blob {
    width: 2.6875rem;          /* 43 */
    height: 2.6rem;            /* 41.6 — the old 31:30 */
    margin-left: -0.4375rem;   /* (43 - 29) / 2 */
  }
  /* The frame's header row sits 2px lower than the desktop padding puts it:
     the circle's centre at 55, not 53. */
  .header-grid { padding-top: 1.125rem; }

  /* Hero: blob 185 wide with its top at 239, keywords 132 below it. */
  .hero-grid {
    padding-top: 14.9375rem;   /* 239 */
    row-gap: 8.25rem;          /* 132 */
  }
  .hero-blob-stage {
    max-width: 11.5625rem;     /* 185 */
    justify-self: center;
  }
  /* The blob's size comes from the 991 block: it matches the cosmos at every
     width up to 991, rather than the frame's 185, which was half its size. */

  /* Sections sit 83 apart; each grid starts 82 under its heading. The 991
     block gives .u-grid a 48 row-gap, which would stack on top of the
     margins below — zeroed on .what-grid so .masonry own its whole offset. */
  .who-grid,
  .what-grid { padding-top: 5.1875rem; }   /* 83 */
  .what-grid { row-gap: 0; }
  .masonry { margin-top: 5.125rem; }       /* 82 */

  /* Inside the copy stack: 35 under the heading, 83 under the body. */
  .who-heading { margin-top: 0; }
  .profile-pic { margin-top: 2.1875rem; }           /* 35 */
  .who-body { margin-top: 2.1875rem; }              /* 35 */
  .who-statement { margin-top: 5.1875rem; }         /* 83 */

  /* The label sits below the image at every width now. Its height is taller
     here than at the design width because the mobile type is proportionally
     larger against its root. */
  .masonry { --m-label-h: 6.4rem; }
  .masonry-label { padding: 1.375rem 0; }   /* 22 */

  /* The frame has no portrait — it was left out of it, not dropped from the
     design — so this size has NO frame reference. Matched to the cosmos so
     the two pieces of mobile media agree. */
  .profile-pic { max-width: 14.5rem; }   /* centring comes from the 991 block */

  /* Footer: nothing here any more. The closing line sits centred at the
     bottom of the screen from the 991 block, and the cosmos spacer is gone
     at these widths. */
}

@media (max-width: 479px) {
  :root {
    --grid-margin: 1rem;
    /* One step in from the 2rem the mobile frame sets, so type is not crowding
       the screen edge on the smallest phones. The frame only describes 393, so
       this step is ours, not read off it. */
    --page-gutter: 1.25rem;  /* 20 */
  }
}
