/* ==================================================================
   gamasierraalta.com
   Chrome is achromatic. The only hue on the site lives in the orb
   field on the landing page (navy → warm amber) and in the two
   atmosphere tokens below that ground it.
   ================================================================== */

/* ------------------------------------------------------------------
   Fonts — served from this domain rather than fonts.googleapis.com.

   The hosted version cost two round trips before a single glyph could be
   asked for: the stylesheet from fonts.googleapis.com, which is itself
   render-blocking, and only once that had parsed did the browser learn
   the fonts.gstatic.com URLs to go and fetch. Lighthouse measured 751 ms
   for the first hop alone on a throttled phone. Declared here, the files
   are same-origin, already covered by the cache rules in .htaccess, and
   the two <link rel="preconnect"> tags they needed are gone with them.

   Copied verbatim from what Google serves — same v25/v24 files, same
   unicode-range split, same variable axes. `font-display: swap` is the
   part that matters for first paint: text is drawn in the fallback
   immediately and re-drawn when the real face lands, so a slow font
   delays nothing.

   latin-ext is declared but will not normally be fetched: unicode-range
   means the browser only asks for a subset once a character in its block
   is actually on the page, and everything currently written here lives in
   latin. It is here so a future case study with, say, a Czech name in it
   does not silently drop to Helvetica for that one word. */

@font-face {
  font-family: 'Archivo';
  font-style: normal;
  font-weight: 100 900;
  font-stretch: 75% 125%;
  font-display: swap;
  src: url('/assets/fonts/archivo-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'Archivo';
  font-style: normal;
  font-weight: 100 900;
  font-stretch: 75% 125%;
  font-display: swap;
  src: url('/assets/fonts/archivo-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/jetbrains-mono-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/jetbrains-mono-latin-ext.woff2') format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

:root {
  --ink: #000000;
  --paper: #F4F4F2;
  --mist: #9B9B98;
  --smoke: #5C5C5A;
  --hair: rgba(244, 244, 242, 0.14);
  --hair-strong: rgba(244, 244, 242, 0.34);

  /* Field anchors — the two ends of the orb ramp, mirrored in field.js */
  --navy: #103A8C;
  --amber: #DE9A40;

  /* How much of the headline shows through before the pointer clears it.

     Both are 0.50 now — the landing used to be 0.11, a deliberate
     near-blackout, back when the veil was the only thing covering the
     words. The pile of digits does that job there now, so the layer
     underneath no longer has to hide; it only has to stay quieter than
     the numbers stacked on top of it. One number across all three pages,
     and the headline is readable standing still everywhere: ~4.8:1
     against this background, which clears WCAG AA for *normal* text,
     never mind the large-text floor these headlines actually fall under.
     The reveal is a flourish on every page rather than a gate.

     They stay two tokens rather than one because the two fields are
     still tuned separately, and the day the landing wants to diverge
     again it should not have to be untangled first. Both are raised by
     `prefers-contrast: more` below. */
  --veil: 0.50;
  --veil-page: 0.50;

  /* Bottom falloff for the interior field layers, so the navy melts into
     the page black instead of stopping on an edge. */
  --field-fade: linear-gradient(to bottom,
    #000 0%,
    #000 52%,
    rgba(0, 0, 0, 0.72) 72%,
    rgba(0, 0, 0, 0.28) 88%,
    transparent 100%);

  --gutter: clamp(20px, 4.2vw, 60px);
  --maxw: 1560px;

  /* Height of the floating masthead on the interior pages. Two things read
     it: the bar's own min-height, and the top padding that gives the page
     back the space the bar no longer occupies in flow. */
  --bar-h: 64px;

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

  --ease: cubic-bezier(0.22, 1, 0.36, 1);
}

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

html, body { height: 100%; }

body {
  margin: 0;
  background: var(--ink);
  color: var(--paper);
  font-family: var(--display);
  font-weight: 400;
  font-size: clamp(15px, 1.05vw, 17px);
  line-height: 1.55;
  letter-spacing: -0.005em;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
}

/* ------------------------------------------------------------------
   Orb field — two canvases straddle the display type so some orbs
   drift behind the headline and some in front of it. That split is
   what gives the field depth; without it the dust looks like a decal.

     z 0  #field-back    far orbs
     z 1  .landing       the headline
     z 2  #field-front   near orbs
     z 3  masthead / colophon (must stay legible)
   ------------------------------------------------------------------ */

#field-back,
#field-front {
  position: fixed;
  inset: 0;
  display: block;
  pointer-events: none;
  opacity: 0;
  transition: opacity 1400ms var(--ease);
}

#field-back  { z-index: 0; }
#field-front { z-index: 2; }

.field-ready #field-back,
.field-ready #field-front { opacity: 1; }

/* Atmosphere the orbs sit in — a cold navy well with one warm ember
   low and right, so the amber end of the ramp has something to belong to. */
body[data-field]::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: -1;
  background:
    radial-gradient(96% 82% at 44% 36%, rgba(16, 58, 140, 0.22), transparent 76%),
    radial-gradient(54% 50% at 84% 92%, rgba(222, 154, 64, 0.08), transparent 76%),
    radial-gradient(130% 120% at 50% 50%, #060A16 0%, #03050C 60%, #000000 100%);
}

/* Fallback when canvas is unavailable */
.no-field body::before {
  background:
    radial-gradient(60% 55% at 46% 34%, rgba(16, 58, 140, 0.34), transparent 70%),
    radial-gradient(40% 40% at 80% 86%, rgba(222, 154, 64, 0.14), transparent 70%),
    radial-gradient(120% 110% at 50% 50%, #070B18 0%, #000000 100%);
}

/* ------------------------------------------------------------------
   Type primitives
   ------------------------------------------------------------------ */

.mono {
  font-family: var(--mono);
  font-size: 11px;
  font-weight: 400;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--mist);
}

/* Inline code — a literal quoted inside a sentence: a CSS unit, a DOM
   interface, a file extension. Not .mono, which is the label treatment:
   it upper-cases, and `vw` and `.md` mean what they mean in the case
   they are written in.

   0.95em, measured rather than assumed. The habitual correction for
   inline code is 0.85em or so, on the theory that a mono face runs
   large next to a sans at the same size. JetBrains Mono does not run
   large next to Archivo — it runs slightly small, so the usual number
   drops the token a visible step below its own sentence, which makes a
   literal read as a footnote to the words around it. At 0.95em the
   x-height lands at 7.90 against the prose's 7.95 and the cap at 10.48
   against 10.37, both inside an eighth of a pixel.

   A fill and no border. The site's surface convention is a hairline at
   0.14 over a 0.02 fill, which is right for a card and wrong here —
   at inline size a border is most of the token's area and two
   characters in a box read as a control someone could press. The fill
   alone is enough to say "this is quoted, not spoken".

   Vertical padding is deliberately small: on an inline box it does not
   push the lines apart, it overlaps the line above, so anything more
   than a hair of it collides in a wrapped paragraph. */
code {
  font-family: var(--mono);
  font-size: 0.95em;
  letter-spacing: 0;
  padding: 0.1em 0.34em;
  border-radius: 4px;
  background: rgba(244, 244, 242, 0.07);
  /* The chip is a rectangle around a word that may break to the next
     line mid-sentence; a break inside it would leave two half-chips. */
  white-space: nowrap;
}

a {
  color: inherit;
  text-decoration: none;
}

a:focus-visible,
button:focus-visible {
  outline: 2px solid var(--paper);
  outline-offset: 4px;
}

/* Underline that draws from the left on hover */
.link {
  position: relative;
  display: inline-block;
  padding-bottom: 2px;
}
.link::after {
  content: '';
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 420ms var(--ease);
}
.link:hover::after,
.link:focus-visible::after { transform: scaleX(1); }

.link--on::after { transform: scaleX(1); }

/* ------------------------------------------------------------------
   Shell
   ------------------------------------------------------------------ */

.shell {
  width: 100%;
  max-width: var(--maxw);
  margin: 0 auto;
  padding: 0 var(--gutter);
}

.masthead {
  position: relative;
  z-index: 3;
  padding-top: clamp(22px, 3.4vw, 40px);
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 24px;
}

.masthead__name {
  position: relative;
  font-size: 14px;
  font-weight: 600;
  letter-spacing: 0.02em;
  text-transform: uppercase;
  color: var(--paper);
}

/* 22px of box, and it is a link home — 2px under the 24px WCAG 2.2 wants.
   Same pseudo-element as the nav beside it, for the same baseline reason. */
.masthead__name::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 44px;
  transform: translateY(-50%);
}

.masthead__nav {
  display: flex;
  gap: clamp(16px, 2.4vw, 34px);
}

/* Primary nav carries the .link underline and full-brightness type; the
   colophon links are the quiet ones. That is the way round it should have
   been: Work / About / Contact is how the site is actually navigated, and
   it was reading as the smaller offer next to two footer links.

   Which page you are on is the underline held open rather than a colour
   change — with every item at --paper now, brightness has nothing left to
   say. It keys off `aria-current` directly so the state cannot drift away
   from what the markup claims. */
.masthead__nav a { position: relative; font-family: var(--mono); font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--paper); }
.masthead__nav a[aria-current='page']::after { transform: scaleX(1); }

/* 11px mono caps is the right size to read at, but it leaves a hit area only
   ~17px tall — well under the 44px a thumb needs. The target grows on a
   pseudo-element instead of padding because .masthead aligns on the baseline,
   and padding would push these links off it. */
.masthead__nav a::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 44px;
  transform: translateY(-50%);
}

/* ------------------------------------------------------------------
   The floating masthead — interior pages only

   These pages are long: a case study runs several screens, and the nav
   sitting at the top of the document meant scrolling all the way back up
   to leave the page you were on. So on anything carrying .page the bar
   comes out of flow and rides above the content.

   The landing keeps its masthead in flow. It is one screen tall by
   design, nothing scrolls past, and a translucent bar over the number
   field would be blurring the one thing the page is there to show.
   ------------------------------------------------------------------ */

.page .masthead {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 40;

  /* Full-bleed bar, contents still on the shell's grid. The gutter is the
     floor; past --maxw the extra is whatever the centred shell leaves over
     on each side, so the name and nav stay in line with the text below
     them however wide the window gets. */
  padding: 0 max(var(--gutter), calc((100% - var(--maxw)) / 2 + var(--gutter)));
  min-height: var(--bar-h);
  align-items: center;

  /* See-through rather than solid: the field and the type keep moving
     underneath, which is the point of floating it. The fallback below
     carries the same bar on engines without backdrop-filter — at that
     opacity it reads as dark chrome instead of glass, which is the right
     way to fail. */
  background: rgba(4, 6, 13, 0.55);
  -webkit-backdrop-filter: blur(16px) saturate(130%);
  backdrop-filter: blur(16px) saturate(130%);
  border-bottom: 1px solid var(--hair);
}

@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .page .masthead { background: rgba(4, 6, 13, 0.92); }
}

/* The space the bar used to take up in flow, handed back. */
.page { padding-top: var(--bar-h); }


/* Anchors and focus rings must not land underneath the bar. scroll-padding
   belongs on the scrolling element, which is the root — :has() is what
   keeps it off the landing, where there is no bar to clear. Engines
   without :has() drop the rule and lose nothing but the allowance. */
:root:has(.page) { scroll-padding-top: calc(var(--bar-h) + 16px); }

/* ------------------------------------------------------------------
   Back to top

   Injected by site.js on .page only — see the note there about why it is
   not in the markup. Same glass as the bar, so the two read as one layer
   floating over the page rather than two unrelated widgets.
   ------------------------------------------------------------------ */

.to-top {
  position: fixed;
  right: clamp(16px, 2.6vw, 40px);
  bottom: clamp(16px, 2.6vw, 40px);
  z-index: 39;

  width: 46px;
  height: 46px;
  display: grid;
  place-items: center;
  padding: 0;
  border: 1px solid var(--hair);
  border-radius: 50%;
  background: rgba(4, 6, 13, 0.55);
  -webkit-backdrop-filter: blur(16px) saturate(130%);
  backdrop-filter: blur(16px) saturate(130%);
  color: var(--paper);
  cursor: pointer;

  /* visibility, not display, so it can transition — and so it leaves the
     tab order entirely while it is out, rather than being a focusable
     control nobody can see. */
  opacity: 0;
  visibility: hidden;
  transform: translateY(10px);
  transition: opacity 320ms var(--ease),
              transform 320ms var(--ease),
              border-color 320ms var(--ease),
              visibility 0s linear 320ms;
}

.to-top.is-on {
  opacity: 1;
  visibility: visible;
  transform: none;
  transition-delay: 0s;
}

.to-top:hover { border-color: var(--hair-strong); }
.to-top svg { display: block; width: 15px; height: 15px; }

@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .to-top { background: rgba(4, 6, 13, 0.92); }
}

/* ------------------------------------------------------------------
   Landing
   ------------------------------------------------------------------ */

.landing {
  position: relative;
  z-index: 1;
  min-height: 100svh;
  display: grid;
  grid-template-rows: auto 1fr auto;
}

.hero {
  display: flex;
  align-items: center;
  /* Tight, because the display type is now doing the breathing — and
     because five lines at 9.5vw only just clear the fold on a 1280×800
     laptop. Scales back up on taller screens. */
  padding: clamp(12px, 2vh, 56px) 0;
}

/* The signature: the headline sits dim under the orb field and is
   uncovered rather than faded in. Two stacked copies — a muted base
   and a lit one masked to a window that follows the cursor. field.js
   writes --mx / --my / --reveal. */
.hero__title {
  /* Still smaller than the cleared void — stars scatter ahead of the
     cursor — but wide enough now to hand back a clause at a time rather
     than two or three words, since there is a second layer of digits to
     get out of the way before any of it is readable. field.js clears
     those over a slightly wider radius again (glyphClear*), so the pile
     is gone before the words come up rather than lifting off them.

     The vw term carries it. It was set from the phone end on purpose:
     a 375px phone reads well because the window covers a big share of
     the headline at once, and 27vw is what puts a desktop at roughly
     the same share of a much wider block. The floor stays near 190px
     because past that a phone has the whole headline lit and there is
     nothing left to walk. */
  --reveal-r: clamp(190px, 27vw, 500px);
  --mx: 50%;
  --my: 50%;
  --reveal: 0;

  position: relative;
  display: block;
  margin: 0;
  font-family: var(--display);
  font-weight: 700;
  font-stretch: 105%;
  /* 25% up from the original clamp(2.55rem, 7.6vw, 8rem). At this size the
     headline still holds four lines above the fold on a 1280×800 laptop. */
  font-size: clamp(3.2rem, 9.5vw, 10rem);
  line-height: 0.9;
  letter-spacing: -0.042em;
  max-width: 20ch;
}

/* Bright by default; only dims once the field is actually running, so a
   failed script leaves a fully readable headline rather than a ghost. */
.hero__title__layer {
  display: block;
  color: var(--paper);
  transition: color 700ms var(--ease);
}

.field-ready .hero__title__layer { color: rgba(244, 244, 242, var(--veil)); }

.hero__title__layer--lit,
.field-ready .hero__title__layer--lit {
  position: absolute;
  inset: 0;
  color: var(--paper);
  opacity: var(--reveal);
  /* No text-shadow here, and that is a performance decision rather than a
     visual one.

     This layer carries a mask whose centre is rewritten every frame from
     field.js (--mx / --my), so the browser has to re-rasterise it on every
     frame that the pointer moves. Re-rasterising plain text is cheap.
     Re-rasterising text with a 44px Gaussian blur under it is not, and the
     two together were the single thing making the landing feel heavy.

     Measured on an M-series Air, pointer moving, 120Hz panel:

       mask + 44px text-shadow   38.8 fps   <- what this used to be
       mask, no text-shadow     120.5 fps
       text-shadow, no mask     120.5 fps

     Neither is expensive alone; it is specifically a blur that has to be
     re-drawn because the mask moved. Shrinking the blur does not buy it
     back either — 32px and 24px both sat at 40 fps, 16px at 57, 8px at
     60. Only removing it returns full frame rate.

     Nothing visible was lost: at rgba(222,154,64,0.16) under a 44px blur
     the halo is imperceptible against this background — screenshots with
     and without it are indistinguishable. If it is ever wanted back, put
     it on the BASE layer instead (the unmasked one). That layer does not
     move, so it rasterises once and costs nothing. */
  -webkit-mask-image: radial-gradient(circle var(--reveal-r) at var(--mx) var(--my),
    #000 0%, rgba(0, 0, 0, 0.86) 38%, rgba(0, 0, 0, 0) 78%);
  mask-image: radial-gradient(circle var(--reveal-r) at var(--mx) var(--my),
    #000 0%, rgba(0, 0, 0, 0.86) 38%, rgba(0, 0, 0, 0) 78%);
}

.hero__title em {
  font-style: normal;
  font-weight: 400;
  color: inherit;
}

/* Touch and tablet get the same reveal as desktop — the well is driven by
   a press and drag instead of a hover (see TOUCH_LIFT in field.js, which
   floats it above the finger so it isn't covering what it uncovers), and
   on touch it also walks the headline on its own from the moment the page
   loads rather than after an idle wait (see `idleStart` in field.js).

   The veil does *not* stay the same. A phone is running the same three
   layers — dust, pile, headline — over a fraction of the area, so a mote
   or a digit on a 51px letter hides much more of it than the same mark
   does on a 122px letter, and the measured 4.8:1 of --veil 0.50 is an
   optimistic reading of what a reader actually gets. 0.64 puts the base
   layer at ~7.3:1 on its own, which leaves real headroom for whatever is
   stacked on top and still keeps the reveal a visible step up. The pile
   and the front dust are pulled back to match in field.js. */
@media (hover: none), (pointer: coarse) {
  :root { --veil: 0.64; }
}

/* ------------------------------------------------------------------
   Supernova — the Konami easter egg (landing, desktop)

   Up up down down left right left right B A. field.js throws every mote
   and every digit outward from the middle of the screen, and this is the
   half of it that isn't canvas: the page chrome clearing out of the way,
   and the words standing in the hole it leaves.
   ------------------------------------------------------------------ */

.nova {
  position: fixed;
  inset: 0;
  /* Deliberately the same layer as .landing, not above it. #field-front
     sits at z 2, so the pile of digits still draws over these words the
     way it draws over the headline — which is the entire point: the words
     are read *through* the numbers, like everything else here. */
  z-index: 1;
  display: grid;
  place-items: center;
  padding: 0 var(--gutter);
  pointer-events: none;
  opacity: 0;
  transition: opacity 420ms var(--ease);
}

.nova.is-on { opacity: 1; }

.nova__word {
  margin: 0;
  font-family: var(--display);
  font-weight: 700;
  font-stretch: 105%;
  font-size: clamp(3.4rem, 11vw, 11rem);
  /* Not the headline's 0.9. The pile is sampled by rasterising this
     element into a canvas the size of its own bounding box, and at 0.9 the
     line box is shorter than the type it contains — so the bottom of every
     letter falls outside the sample and the digits land on the top half of
     the words only. One line of display type looks no different at 1.15;
     the difference is that the box now contains the glyphs. */
  line-height: 1.15;
  letter-spacing: -0.045em;
  text-align: center;
  /* Dim, for the same reason --veil is dim: the digits stacked on top are
     what you read it through. Brighter than the headline's veil because
     these words only get three seconds to land. */
  color: rgba(244, 244, 242, 0.46);
  text-shadow: 0 0 60px rgba(222, 154, 64, 0.22);
}

/* No transform on this element, deliberately. field.js rasterises it to
   find where the digits go, and getBoundingClientRect reports the
   *transformed* box — so a scale still in flight when the pile is sampled
   puts every digit somewhere the letters no longer are. The entrance is
   carried by opacity on the wrapper and by the digits assembling out of
   the blast, which is motion enough. */

/* Everything the blast clears. One rule, keyed off a class on <html> that
   field.js drops the frame the return finishes — so the way back is the
   same transition run backwards, and there is no second timer to keep in
   step with the one driving the canvas. */
/* Qualified by .landing purely for weight — the colophon carries .reveal
   itself, and these have to outrank it. The entrance animation is no
   longer in the way: it fills `backwards`, so once it has run the element
   is back under the normal cascade and a plain opacity: 0 here wins. */
.nova-on .landing .masthead,
.nova-on .landing .hero,
.nova-on .landing .colophon {
  opacity: 0;
  transition: opacity 300ms var(--ease);
}

@media (prefers-contrast: more) {
  :root { --veil: 0.72; --veil-page: 0.92; }
}

.hero__meta {
  margin-top: clamp(16px, 1.8vw, 28px);
  display: flex;
  flex-wrap: wrap;
  gap: 10px 28px;
  align-items: baseline;
}

.hero__meta p { margin: 0; }

.colophon {
  position: relative;
  z-index: 3;
  padding-bottom: clamp(22px, 3.4vw, 40px);
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 14px 28px;
  border-top: 1px solid var(--hair);
  padding-top: 18px;
}

/* No links left above it — the divider has nothing to separate. */
.colophon--bare { border-top: 0; padding-top: 0; }

.colophon__links {
  display: flex;
  flex-wrap: wrap;
  gap: clamp(16px, 2.2vw, 32px);
}

.colophon a { position: relative; font-family: var(--mono); font-size: 11px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--mist); transition: color 300ms var(--ease); }
.colophon a:hover,
.colophon a:focus-visible { color: var(--paper); }

/* Same problem the masthead nav has, and the same fix: 11px mono caps is the
   right size to read at and leaves a hit area ~18px tall, under the 24px
   WCAG 2.2 asks for and well under the 44px a thumb actually wants. The
   target grows on a pseudo-element rather than on padding because the
   colophon is a baseline-aligned row next to the © line, and padding would
   push these links off it and open the divider gap. 44px matches the
   masthead so both rows of links behave the same under a thumb. */
.colophon__links a::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: 50%;
  height: 44px;
  transform: translateY(-50%);
}

/* ------------------------------------------------------------------
   Interior pages
   ------------------------------------------------------------------ */

/* The floor was 60px. The back-to-top button is fixed to the viewport and
   reaches 86px up from its bottom edge at the widest clamp, so at the end of
   a page it would come to rest on top of the colophon's © line, which sits
   on the same side. 96px of tail keeps the button in empty space instead. */
.page { padding-bottom: clamp(96px, 10vh, 140px); }

.page__head {
  position: relative;
  /* Anchors the field. The canvases and the gradient deliberately bleed
     *outward* from this box — up behind the masthead and out to both
     viewport edges — using the --bleed-* offsets field.js measures, so the
     atmosphere reaches the top of the page the way the landing's does.
     `isolation` keeps their negative z-index inside this subtree; there is
     no `overflow: hidden` because the bleed depends on escaping the box,
     and a canvas clips its own drawing anyway.

     No border-bottom either: the field's bottom fade is the separator now,
     and a hard 1px rule would undo the point of it. */
  isolation: isolate;
  padding: clamp(56px, 11vh, 140px) 0 clamp(66px, 10vh, 130px);
}

/* ------------------------------------------------------------------
   Interior headline field — the same number field as the landing,
   anchored to .page__head instead of the viewport. field.js injects both
   canvases; they straddle the type exactly the way the landing's pair
   does, so motes drift under some letters and over others.

     z -2  ::before        atmosphere gradient
     z -1  --back canvas   far motes
     z  0  the headline    (ordinary in-flow content)
     z  1  --front canvas  near motes
     z  3  .masthead       above the lot, so motes never sit on the nav
                           (same order the landing uses)
   ------------------------------------------------------------------ */

[data-field-scope]::before {
  content: '';
  position: absolute;
  z-index: -2;

  /* Bleed out of .page__head: up to the top of the page and across to
     both viewport edges. field.js writes these from measured geometry —
     they're scroll-stable, so this only changes on resize. */
  top: calc(-1 * var(--bleed-top, 0px));
  left: calc(-1 * var(--bleed-left, 0px));
  right: calc(-1 * var(--bleed-right, 0px));
  bottom: 0;

  background:
    radial-gradient(120% 150% at 28% 10%, rgba(16, 58, 140, 0.28), transparent 74%),
    radial-gradient(70% 100% at 90% 105%, rgba(222, 154, 64, 0.11), transparent 76%),
    radial-gradient(160% 210% at 50% 30%, #060A16 0%, #03050C 55%, #000000 100%);
}

.number-field {
  position: absolute;
  display: block;
  pointer-events: none;
  opacity: 0;
  transition: opacity 1200ms var(--ease);
}

.number-field--back  { z-index: -1; }
.number-field--front { z-index: 1; }

.field-ready .number-field { opacity: 1; }

/* Only the field layers are masked, never the container — mask .page__head
   itself and the lede would dissolve along with the atmosphere. */
[data-field-scope]::before,
[data-field-scope] .number-field {
  -webkit-mask-image: var(--field-fade);
  mask-image: var(--field-fade);
}

/* Same two-layer treatment as the landing headline — a veiled base and a
   lit copy masked to a window that follows the well — but the base sits
   at --veil-page (0.50), so this reads perfectly well without ever being
   touched. The reveal just brightens what you're already reading. */
.page__title {
  --reveal-r: clamp(150px, 15vw, 260px);
  --mx: 50%;
  --my: 50%;
  --reveal: 0;

  position: relative;
  display: block;
  margin: 14px 0 0;
  font-weight: 700;
  font-stretch: 105%;
  font-size: clamp(2.1rem, 5.4vw, 4.6rem);
  line-height: 0.95;
  letter-spacing: -0.038em;
  max-width: 22ch;
}

/* Bright by default; only drops to the veil once the field is actually
   running, so a failed script leaves a fully readable headline. */
.page__title__layer {
  display: block;
  color: var(--paper);
  transition: color 700ms var(--ease);
}

.field-ready .page__title__layer { color: rgba(244, 244, 242, var(--veil-page)); }

.page__title__layer--lit,
.field-ready .page__title__layer--lit {
  position: absolute;
  inset: 0;
  color: var(--paper);
  opacity: var(--reveal);
  /* No text-shadow, for the reason spelled out on .hero__title__layer--lit
     above: a moving mask forces a re-raster every frame, and a blur under
     that re-raster is what cost the landing two thirds of its frame rate.
     The interior fields run the same mask on the same kind of layer. */
  -webkit-mask-image: radial-gradient(circle var(--reveal-r) at var(--mx) var(--my),
    #000 0%, rgba(0, 0, 0, 0.86) 38%, rgba(0, 0, 0, 0) 78%);
  mask-image: radial-gradient(circle var(--reveal-r) at var(--mx) var(--my),
    #000 0%, rgba(0, 0, 0, 0.86) 38%, rgba(0, 0, 0, 0) 78%);
}

.page__lede {
  margin: clamp(20px, 3vw, 30px) 0 0;
  max-width: 62ch;
  font-size: clamp(1.05rem, 1.5vw, 1.32rem);
  line-height: 1.5;
  letter-spacing: -0.012em;
  color: var(--paper);
}

/* Work index --------------------------------------------------- */

/* Filter chips. Hidden in the markup and revealed by work.js, so a
   visitor without JavaScript gets the whole grid rather than a row of
   controls that do nothing. */
.filters {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  margin: clamp(30px, 4vw, 52px) 0 0;
}

/* An icon rather than the word "Filter": the row is self-evidently a
   filter once the chips are read, and the label was spending a chip's
   worth of width saying so. */
.filters__icon {
  display: inline-flex;
  margin-right: 4px;
  color: var(--smoke);
}
.filters__icon svg { display: block; width: 16px; height: 16px; }

.chip {
  display: inline-flex;
  align-items: center;
  gap: 0;
  padding: 7px 13px;
  border: 1px solid var(--hair);
  border-radius: 999px;
  background: transparent;
  color: var(--mist);
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  cursor: pointer;
  transition: color 220ms var(--ease), border-color 220ms var(--ease),
              background-color 220ms var(--ease);
}

.chip:hover { color: var(--paper); border-color: var(--hair-strong); }

/* Selected chips invert. Colour alone would be carrying the state, so
   the checkmark carries it too — the same reason aria-pressed is on
   the button rather than a class. */
.chip[aria-pressed="true"] {
  color: var(--ink);
  background: var(--paper);
  border-color: var(--paper);
}

/* The check grows out of zero width, so an unselected chip is not
   padded out with a gap waiting for a mark that is not there. */
.chip__check {
  display: inline-flex;
  width: 0;
  margin-right: 0;
  opacity: 0;
  overflow: hidden;
  transition: width 200ms var(--ease), margin-right 200ms var(--ease),
              opacity 160ms var(--ease);
}
.chip__check svg { display: block; width: 11px; height: 11px; }

.chip[aria-pressed="true"] .chip__check {
  width: 11px;
  margin-right: 7px;
  opacity: 1;
}

.chip__count { margin-left: 7px; opacity: 0.5; }

/* "All" is the resting state of the row, not a reset button that turns
   up once you have filtered. It sits first, it is pressed whenever
   nothing else is, and it is in the same place whether or not you have
   touched anything — so the way back to everything never has to be
   found. */
.chip--all { margin-right: 4px; }

/* The count of what survived the filter. Spoken on every change and
   printed for everyone else — a grid that silently drops from six
   cards to two should say so. */
.filters__status {
  margin: 18px 0 0;
  color: var(--smoke);
}

/* Grid ----------------------------------------------------------- */

.grid {
  list-style: none;
  margin: clamp(18px, 2.4vw, 28px) 0 0;
  padding: 0;
  display: grid;
  /* min() rather than a bare 320px: below 360px viewports the track
     would otherwise be wider than the column it sits in and push the
     page sideways. */
  grid-template-columns: repeat(auto-fill, minmax(min(330px, 100%), 1fr));
  gap: clamp(16px, 2vw, 26px);
}

.card[hidden] { display: none; }

.card__link {
  /* A column, so the body can stretch and the keyword line can be
     pushed to the floor of the card. Cards in a row are already equal
     height; without this the keywords sit wherever the description
     happens to end and the row reads as ragged. */
  display: flex;
  flex-direction: column;
  height: 100%;
  color: inherit;
  text-decoration: none;
  border: 1px solid var(--hair);
  border-radius: 12px;
  overflow: hidden;
  background: rgba(244, 244, 242, 0.02);
  transition: border-color 300ms var(--ease), background-color 300ms var(--ease),
              transform 300ms var(--ease);
}

.card__link:hover,
.card__link:focus-visible {
  border-color: var(--hair-strong);
  background: rgba(244, 244, 242, 0.05);
  transform: translateY(-2px);
}

/* Thumbnails are six screenshots of six different tools, and left
   alone they fight each other and the page. They are held back to 0.82
   under a dark scrim so the grid reads as one surface, and come up to
   full on hover — the card you are pointing at is the one allowed to
   be bright. */
.card__media {
  position: relative;
  display: block;
  aspect-ratio: 16 / 10;
  overflow: hidden;
  background: #07070A;
  border-bottom: 1px solid var(--hair);
}

.card__media img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0.82;
  transition: opacity 400ms var(--ease), transform 600ms var(--ease);
}

.card__media::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(to bottom, rgba(3, 5, 12, 0.14), rgba(3, 5, 12, 0.50));
}

.card__link:hover .card__media img,
.card__link:focus-visible .card__media img {
  opacity: 1;
  transform: scale(1.02);
}

.card__body {
  flex: 1;
  display: flex;
  flex-direction: column;
  padding: clamp(18px, 2vw, 24px);
}

.card__meta { display: flex; align-items: center; gap: 10px; }

.card__year { font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em; color: var(--mist); }

.card__status {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink);
  background: var(--paper);
  padding: 4px 9px;
}

.card__status--quiet {
  color: var(--mist);
  background: transparent;
  border: 1px solid var(--hair);
  padding: 3px 8px;
}

.card__name {
  margin: 12px 0 0;
  font-size: clamp(1.18rem, 1.9vw, 1.55rem);
  font-weight: 600;
  letter-spacing: -0.026em;
  line-height: 1.12;
}

.card__role {
  margin: 10px 0 0;
  color: var(--mist);
  font-size: 0.95rem;
  line-height: 1.5;
}

/* margin-top:auto is what lands it on the floor. The description stays
   where it is — only the keywords move, so the eye still reads each
   card top-down and finds the same last line across the row. */
.card__tags {
  margin: auto 0 0;
  padding-top: 18px;
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--smoke);
  line-height: 1.7;
}

.work-empty { margin: clamp(28px, 4vw, 44px) 0 0; color: var(--mist); }
.work-empty[hidden] { display: none; }

@media (prefers-reduced-motion: reduce) {
  .card__link,
  .card__media img,
  .chip,
  .chip__check { transition: none; }
  .card__link:hover,
  .card__link:focus-visible { transform: none; }
  .card__link:hover .card__media img { transform: none; }
}

/* About -------------------------------------------------------- */

.rows { margin-top: clamp(40px, 6vw, 80px); }

.row {
  display: grid;
  grid-template-columns: minmax(0, 22ch) minmax(0, 1fr);
  gap: clamp(14px, 3vw, 56px);
  padding: clamp(26px, 3.6vw, 44px) 0;
  border-bottom: 1px solid var(--hair);
}

.row__label { padding-top: 0.35em; }

.row__body { max-width: 68ch; }
.row__body > p { margin: 0 0 1em; }
.row__body > p:last-child { margin-bottom: 0; }

.stack { list-style: none; margin: 0; padding: 0; }
.stack li { padding: 10px 0; border-bottom: 1px solid var(--hair); }
.stack li:last-child { border-bottom: 0; }
.stack strong { font-weight: 600; letter-spacing: -0.012em; }
.stack span { color: var(--mist); }

.inline-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px 10px; }
.inline-list.page-tags { margin-top: clamp(28px, 4vw, 44px); }
.inline-list li {
  font-family: var(--mono);
  font-size: 10px;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--mist);
  border: 1px solid var(--hair);
  border-radius: 999px;
  padding: 5px 11px;
}

.contact {
  margin-top: clamp(50px, 8vw, 100px);
  padding-top: clamp(30px, 4vw, 50px);
  border-top: 1px solid var(--hair);
}

.contact__line {
  margin: 16px 0 0;
  font-weight: 600;
  font-size: clamp(1.6rem, 4.2vw, 3.2rem);
  letter-spacing: -0.036em;
  line-height: 1.05;
}

/* CTA arrow — the trail slides out on hover/focus so the big text
   reads unmistakably as a link, not a heading. */
.contact__cta { display: inline-flex; align-items: center; gap: clamp(14px, 2vw, 22px); }

.contact__arrow { display: block; flex: none; color: var(--mist); transition: transform 300ms var(--ease), color 300ms var(--ease); }
.contact__arrow svg { display: block; width: clamp(22px, 3vw, 34px); height: clamp(22px, 3vw, 34px); }

.contact__cta:hover .contact__arrow,
.contact__cta:focus-visible .contact__arrow {
  color: var(--paper);
  transform: translateX(10px);
}

.contact__arrow--back svg { transform: scaleX(-1); }
.contact__cta:hover .contact__arrow--back,
.contact__cta:focus-visible .contact__arrow--back {
  transform: translateX(-10px);
}

/* Case study ---------------------------------------------------- */

/* Case studies reuse the About page's .row grid, but their section labels
   are real <h2>s so a long page has an outline to navigate. Resetting the
   margin on the heading only leaves About's decorative <p> labels alone. */
h2.row__label { margin: 0; }

/* Sub-heads inside a section body. Deliberately quiet — the mono label in
   the left column is the section marker; this only has to separate two
   ideas inside one section, not compete with it. */
.row__body h3 {
  margin: clamp(30px, 3.4vw, 42px) 0 12px;
  font-size: 1rem;
  font-weight: 600;
  letter-spacing: -0.012em;
}
.row__body > h3:first-child { margin-top: 0; }

/* Bullet list. .stack's hairline rules read as a table of records — right
   for About's job history, wrong for a list of points, where they chop the
   section into slabs. These get a marker instead: the site's own mark
   shrunk to a 6px ring, sat on the first line's x-height rather than the
   top of the line box so it reads level with the text. */
.bullets { list-style: none; margin: 0; padding: 0; }

.bullets li {
  position: relative;
  margin-bottom: 12px;
  padding-left: 22px;
}
.bullets li:last-child { margin-bottom: 0; }

.bullets li::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0.58em;
  width: 6px;
  height: 6px;
  border: 1px solid var(--mist);
  border-radius: 50%;
}

/* Prose that continues after a list — the list carries no bottom margin
   of its own, so without this it butts straight into the last item. */
.row__body .bullets + p { margin-top: 1em; }

/* Prose resuming after a diagram. Every component in the set below leads
   with a top margin and carries none at the bottom — correct, because each
   is introduced by the text above it. But when prose picks up again
   underneath, nothing holds it off, and it reads as a caption on the
   diagram rather than as the next thought. This was found twice as a
   one-off (.bullets, then .annot) before it was worth stating once for the
   whole set. 1em is deliberately not enough here: these are blocks, not
   list items, and they need a real pause on both sides.

   Specificity is doing real work — `.pair__text > p` and `.row__body > p`
   both set margin-top to 0 and both outrank a plain descendant selector, so
   the set sits in :where() (weightless) and the leading class is doubled to
   clear them. */
.rows--case .row__body
  :where(.matrix, .chain, .trail, .converge, .compare, .swap, .scatter, .shells, .annot)
  + :is(p, blockquote) { margin-top: clamp(24px, 2.8vw, 34px); }

/* The last section's rule and .contact's top border land within a screen
   of each other and read as a double line. The section rules are what
   structure the page, so .contact's is the one that stays. */
.rows--case .row:last-child,
.rows .row:last-child { border-bottom: 0; }

/* Pull quote. No quote glyphs in the markup — the rule and the step up in
   size already mark it, and real quotation marks would hang into the
   gutter or sit crooked against the border. Same size as .page__lede. */
.pull {
  margin: clamp(30px, 3.4vw, 42px) 0;
  padding-left: clamp(18px, 2.4vw, 28px);
  border-left: 1px solid var(--hair-strong);
  font-size: clamp(1.05rem, 1.5vw, 1.32rem);
  line-height: 1.45;
  letter-spacing: -0.012em;
  color: var(--paper);
}
.pull p { margin: 0; }

/* A pull quote stepped back down to body size. Both case studies run a
   spine of .statement lines that carry the argument when the page is
   scanned; a pull quote at .page__lede size sits close enough to those to
   read as one more of them. The left rule stays, so the line is still
   marked as an aside — it just stops competing for the eye.

   A modifier rather than a change to .pull itself, so the plain .pull
   remains available to a page that has no spine to compete with. */
.pull--quiet {
  font-size: 1rem;
  line-height: 1.55;
  letter-spacing: -0.005em;
}

.figure { margin: clamp(30px, 3.6vw, 44px) 0 0; }
/* A figure with prose after it needs the same air underneath as above.
   Without this the next paragraph starts one line below a caption that is
   already quiet, low-contrast type, and reads as a continuation of the
   caption instead of the next thought. Only when something follows: a
   figure that ends a row already has the row's own spacing under it. */
.figure:not(:last-child) { margin-bottom: clamp(30px, 3.6vw, 44px); }

/* A screenshot of a 390pt phone captured at 3x is three times life size,
   and at the full text measure a UI detail stops illustrating the column
   and starts dominating it. This caps those to about half the measure
   once there is room for it. Below 1001px the column is narrow enough
   that halving it would only make the detail unreadable, so the cap
   lifts and the figure goes back to full width. */
@media (min-width: 1001px) {
  .figure--half { max-width: 52%; }
}
/* Video sits here alongside img because a figure holding a UI clip should
   be indistinguishable from one holding a screenshot — same width, same
   corner, same place in the rhythm. Playback is the only difference, and
   that is handled in site.js. */
.figure img,
.figure video { display: block; width: 100%; height: auto; border-radius: 10px; }
/* Figure captions. Deliberately not .mono: that is a label treatment — 11px,
   uppercase, 0.16em of tracking — which is right for "01 · THE CHALLENGE" and
   was fine on the placeholders, but a caption is a sentence somebody is meant
   to actually read, and tracked-out uppercase fights that at any length past
   a few words. So this uses the same quiet register the page already gives
   secondary prose everywhere else: --mist against --ink, one step down from
   body, capped at a caption-length measure so it never runs the full width of
   the artwork above it. */
.figure figcaption {
  margin-top: 14px;
  max-width: 60ch;
  font-size: 0.94rem;
  line-height: 1.5;
  letter-spacing: -0.005em;
  color: var(--mist);
}

/* Slot for artwork that hasn't been made yet. Dashed rather than solid so
   it never reads as a finished frame. To fill one: swap the inner <p> for
   an <img> and drop the --empty modifier — nothing else changes. */
.figure--empty {
  display: grid;
  place-items: center;
  min-height: clamp(150px, 20vw, 240px);
  padding: 24px;
  border: 1px dashed var(--hair);
  border-radius: 10px;
  text-align: center;
}
.figure--empty p { margin: 0; max-width: 46ch; }

/* Case study — diagram set ---------------------------------------- */

/* The research-practice page argues that scattered work was turned into a
   system, and a page made only of paragraphs cannot argue that. So the
   diagrams below are drawn in HTML and CSS rather than shipped as flat
   artwork: the labels stay real text (selectable, translatable, legible at
   320px), the rules stay hairlines at any zoom, and none of it needs an
   asset pipeline or a second render for dark mode.

   All of it is achromatic. The only hue on the site still lives in the
   field; emphasis here is carried by weight and by --paper against --mist.
   --smoke is deliberately absent — it measures ~3:1 on black, which is
   under the floor for the small mono labels these components are full of. */

/* A section that needs the full content width for a diagram. The label
   moves above the body instead of beside it, and prose inside stays capped
   at the same measure as every other row so only the artwork goes wide.
   Below 900px every row is single-column anyway, so the two converge. */
.rows--case .row--wide { grid-template-columns: 1fr; gap: clamp(16px, 2.2vw, 26px); }
.rows--case .row--wide .row__label { padding-top: 0; }
.rows--case .row--wide .row__body { max-width: none; }
/* Prose in a wide row keeps the same measure as every other row, so only
   the artwork actually goes wide. Wrapped in :where() so the rule carries
   no specificity: `ch` resolves against the element's own font size, and at
   40px .statement would read this as 1564px and swallow its own 26ch cap.
   A component that sets its own measure now simply keeps it. */
:where(.row--wide .row__body) > :where(p, h3, .bullets) { max-width: 68ch; }
:where(.row--wide .row__body) > :where(.pull) { max-width: 52ch; }

/* ---- Text beside the diagram that explains it -------------------

   Before this, prose ran at 68ch and diagrams at the full 1320px content
   width. That left ~730px of void beside every paragraph and made every
   diagram 2.2× the width of the text explaining it — which inverts the
   hierarchy, since size is the first thing that tells a reader where to
   look. Equal columns put the diagram and its explanation at the same
   weight and close the gap.

   The columns only exist where both halves stay usable (see the
   min-width block below). Until then .pair is ordinary flow, so the
   stacked reading order is whatever the markup says. */

.pair > * + * { margin-top: clamp(26px, 3.4vw, 40px); }

/* Two paired blocks in one section — the AI practice page runs a second
   pair where the first one's diagram has finished making its point. Both
   columns zero their own first child's top margin (see below), so without
   this the second pair's opening sub-head butts straight into the bottom
   of the diagram above it. Wider than the gap inside a pair: this is a
   change of subject, not a change of column. */
.pair + .pair { margin-top: clamp(38px, 4.4vw, 60px); }

/* .row__body's own child combinators stop reaching prose once it moves a
   level down, so paragraph rhythm is restored here. Descendant rules
   (.row__body h3, .pull, .statement) still apply and are left alone. */
.pair__text > p { margin: 0 0 1em; }
.pair__text > p:last-child { margin-bottom: 0; }

/* The rule above is `.pair__text > p` (0,1,1), which outranks `.statement`
   (0,1,0) — so the narrative statements were silently taking a plain 1em
   paragraph gap instead of the pause they exist to create. Restored here
   with enough specificity to win. Top margin stays 0: all four open their
   column, and the column top is already the pause. */
.pair__text > p.statement { margin: 0 0 clamp(34px, 3.6vw, 52px); }

/* Nearly every component leads with a top margin meant to separate it
   from prose above. At the top of a column there is nothing above it. */
.pair__text > :first-child,
.pair__art > :first-child { margin-top: 0; }

@media (min-width: 1001px) {
  .pair {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: clamp(30px, 3.2vw, 54px);
    align-items: start;
  }
  .pair > * + * { margin-top: 0; }

  /* Sections where the outline wants the picture before the explanation
     (01 — see the mess, then read why it was a mess) keep the art first
     in the markup, so it still leads when the columns stack. Placement
     is explicit here so it lands on the right regardless. */
  .pair--lead-art > .pair__art  { grid-column: 2; grid-row: 1; }
  .pair--lead-art > .pair__text { grid-column: 1; grid-row: 1; }

  /* Columns top-align by default, which is right when the diagram is the
     thing the opening sentence is pointing at. Where one side is much
     shorter than the other, that leaves a block of void under it and the
     dead space simply moves rather than goes — so those pairs centre
     instead. Centring is a no-op on the taller column, which sets the row
     height, so this only ever moves the short one. */
  .pair--center { align-items: center; }

  /* A dashed placeholder has no intrinsic height to balance against the
     column beside it. The Confluence screenshot it stands in for is tall. */
  .pair__art .figure--empty { min-height: clamp(240px, 30vw, 420px); }

  /* The text track is a little wider than 68ch; the prose is not. Keeps
     the measure identical to every other page. :where() again so
     .statement and .swap keep the measures they set for themselves. */
  :where(.pair__text) > :where(p, h3, .bullets, ul, ol, dl) { max-width: 68ch; }
  :where(.pair__text) > :where(.pull) { max-width: 52ch; }

  /* Diagrams drawn for a full-width row need their counts revisited at
     half that width. Fixed counts, not auto-fit: at ~630px auto-fit picks
     whatever fits and orphans the last item onto a row of its own. */
  .pair__art .trail   { grid-template-columns: repeat(3, minmax(0, 1fr)); }
  .pair__art .compare { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .pair__art .matrix  { grid-template-columns: minmax(0, 20ch) minmax(0, 1fr); }

  /* Annotations belong in a numbered column down the side of the
     screenshot they annotate, not in a 2×2 block under it. */
  .pair__text .annot { grid-template-columns: 1fr; gap: clamp(18px, 2vw, 24px); }

  /* A figure that is evidence for the column beside it rather than the
     argument itself. The Confluence template is a tall portrait screenshot;
     at full column width it stood as high as the annotations explaining it
     and read as the section's headline instead of its footnote. Capped so
     the prose leads. Only inside a paired column — once the layout stacks,
     a dense screenshot needs every pixel it can get to stay legible. */
  .pair__art .figure--inset { max-width: 68%; }

  /* A pair is only a pair where the row is wide enough to hold two readable
     columns. .pair grids on a viewport query, not a container one, so inside
     a narrow row — whose body is capped at the 68ch measure — it would still
     split into two ~265px columns and wreck both halves. Narrow rows keep the
     markup and the reading order, and simply stack. This is what lets a
     section switch between the two rhythms by adding or dropping one class. */
  .row:not(.row--wide) .pair { display: block; }
  .row:not(.row--wide) .pair > * + * { margin-top: clamp(26px, 3.4vw, 40px); }
  .row:not(.row--wide) .pair__art .figure--inset { max-width: 100%; }
}

/* Credit line under the hero, on both case studies. The opening is meant to
   breathe, so the housekeeping — role, team, timeline, status — sits inline
   here as a short dl rather than taking a labelled row in the case grid,
   and section 01 opens on the argument instead. Flex-wrapped, so the field
   count can differ per page without either one needing its own rule. */
.meta-row {
  display: flex;
  flex-wrap: wrap;
  gap: 16px clamp(24px, 4vw, 54px);
  margin: clamp(24px, 3vw, 34px) 0 0;
}
.meta-row dt { margin: 0 0 6px; }
.meta-row dd { margin: 0; max-width: 34ch; }

/* One-line arguments the page turns on. Sized between .page__lede and
   .contact__line — big enough to stop a scroll, small enough that it never
   competes with the headline three screens up. */
.statement {
  margin: clamp(34px, 4vw, 52px) 0;
  max-width: 26ch;
  font-size: clamp(1.5rem, 3.4vw, 2.5rem);
  font-weight: 600;
  line-height: 1.08;
  letter-spacing: -0.032em;
  color: var(--paper);
}
.statement em { font-style: normal; color: var(--mist); }

/* Same specificity problem as `.pair__text > p.statement` above, in the
   other place a statement lives: `.row__body > p` (0,1,1) outranks
   `.statement` (0,1,0), so on Benchmarks AI — where the spine sits in
   plain rows rather than paired columns — the four statements were taking
   a 1em paragraph gap instead of the pause they exist to create.

   First-child gets no top margin: every statement opens its section, and
   the row's own padding is already the pause. Without this the statement
   would also drop out of alignment with the mono label beside it. */
.row__body > p.statement { margin: clamp(34px, 4vw, 52px) 0; }
.row__body > p.statement:first-child { margin-top: 0; }

/* 01 — the inherited mess. Three columns of artifacts drop into a shared
   rail and then into a node full of question marks. Dashed throughout,
   because none of these were connections anyone designed. */
.scatter { margin: clamp(30px, 3.6vw, 44px) 0 0; }

.scatter__cols {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: clamp(12px, 2vw, 24px);
}

.scatter__col { position: relative; min-width: 0; padding-bottom: 34px; }
.scatter__col::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: 0;
  height: 30px;
  border-left: 1px dashed var(--hair-strong);
}

.scatter__col > p { margin: 0 0 14px; }

.scatter__col ul { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.scatter__col li {
  padding: 9px 12px;
  border: 1px solid var(--hair);
  border-radius: 6px;
  background: rgba(244, 244, 242, 0.02);
  font-family: var(--mono);
  font-size: 11px;
  letter-spacing: 0.01em;
  color: var(--mist);
  overflow-wrap: anywhere;
}

/* Half a degree of tilt each — enough that the column reads as a pile
   rather than a list, not enough that anything looks broken. */
.scatter__col li:nth-child(3n+1) { transform: rotate(-0.5deg); }
.scatter__col li:nth-child(3n+2) { transform: rotate(0.45deg); }
.scatter__col li:nth-child(3n+3) { transform: rotate(-0.22deg); }

/* The rail runs the full width rather than stopping at the outer column
   centres: gaps make those centres a few px off a clean 1/6 and 5/6, and a
   dashed line that misses its drop by 8px just looks like a mistake. */
.scatter__funnel { position: relative; height: clamp(40px, 5vw, 56px); }
.scatter__funnel::before {
  content: '';
  position: absolute;
  inset: 0 0 auto;
  border-top: 1px dashed var(--hair-strong);
}
.scatter__funnel::after {
  content: '';
  position: absolute;
  left: 50%;
  top: 0;
  bottom: 0;
  border-left: 1px dashed var(--hair-strong);
}

.scatter__sink {
  width: min(100%, 40ch);
  margin: 0 auto;
  padding: clamp(18px, 2.2vw, 26px);
  border: 1px dashed var(--hair-strong);
  border-radius: 10px;
  text-align: center;
}
.scatter__sink p { margin: 0; }
.scatter__q {
  font-family: var(--mono);
  font-size: clamp(15px, 2vw, 20px);
  letter-spacing: 0.42em;
  color: var(--mist);
}
.scatter__sink .mono { margin-top: 12px; }

/* 02 — the discovery sequence. One row of cells divided by the grid gap
   showing through as hairlines, so there is no border to double up. */
.trail {
  list-style: none;
  margin: clamp(30px, 3.6vw, 44px) 0 0;
  padding: 0;
  display: grid;
  /* Fixed counts rather than auto-fit. There are exactly six steps, and
     auto-fit picks whatever fits — which is 5 at 768px, orphaning the last
     one on its own row. 3 and 2 both divide 6, so the sequence never ends
     on a short row at any width. Three across also suits the half-width
     column this now lives in far better than six would. */
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 1px;
  background: var(--hair);
  border: 1px solid var(--hair);
  border-radius: 10px;
  overflow: hidden;
}
.trail li { background: var(--ink); padding: clamp(16px, 2vw, 22px) clamp(14px, 1.6vw, 18px); }
.trail__n { display: block; margin-bottom: 10px; }
.trail__t { display: block; letter-spacing: -0.012em; }

/* 04 — the five stages, and the return that makes them a loop. The return
   is a rail rather than a curve: a rail stays exact at any width, and a
   curve would need an SVG that distorts the moment the box is not square. */
.loop {
  /* Lives on the parent so the return rail can solve for it too. */
  --g: clamp(10px, 1.4vw, 18px);
  /* The bottom margin is not decoration. At full width .loop__label is
     absolutely positioned and translated to straddle the rail, so it hangs
     ~8px below .loop's own box — with no margin the label collided with
     the first line of the paragraph underneath. This clears the overhang
     and then leaves a real gap. */
  margin: clamp(34px, 4vw, 50px) 0 clamp(48px, 5vw, 68px);
}

.loop__stages {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: var(--g);
}

.loop__stage {
  position: relative;
  min-width: 0;
  padding: clamp(18px, 2vw, 24px) clamp(14px, 1.4vw, 18px);
  border: 1px solid var(--hair);
  border-radius: 10px;
}
.loop__stage p { margin: 0; color: var(--mist); }
.loop__name {
  margin: 0 0 10px;
  font-size: 1rem;
  font-weight: 600;
  letter-spacing: -0.014em;
  color: var(--paper);
}
.loop__n { display: block; margin-bottom: 12px; }

/* Connector and arrowhead sitting in the gap to the left of every stage
   after the first. */
.loop__stage + .loop__stage::before {
  content: '';
  position: absolute;
  top: 50%;
  left: calc(-1px - var(--g));
  width: var(--g);
  border-top: 1px solid var(--hair-strong);
}
.loop__stage + .loop__stage::after {
  content: '';
  position: absolute;
  top: 50%;
  left: calc(1px - var(--g));
  width: 5px;
  height: 5px;
  border-top: 1px solid var(--hair-strong);
  border-right: 1px solid var(--hair-strong);
  transform: translateY(-50%) rotate(45deg);
}

.loop__return {
  position: relative;
  height: clamp(44px, 5vw, 60px);
  /* The arms land exactly on the first and last card's centre. For five
     equal columns of width c separated by gap g, c/2 = W/10 − 0.4g — so
     this stays on centre at every width instead of drifting a few px the
     way a flat 10% inset does. */
  margin: 0 calc(10% - 0.4 * var(--g));
  border: 1px solid var(--hair-strong);
  border-top: 0;
  border-radius: 0 0 14px 14px;
}
/* Arrowhead at the top of the left arm, pointing back up into Discovery. */
.loop__return::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0;
  width: 6px;
  height: 6px;
  border-top: 1px solid var(--hair-strong);
  border-left: 1px solid var(--hair-strong);
  transform: translate(-3.5px, -0.5px) rotate(45deg);
}
/* Sits on the rail and punches a hole in it. */
.loop__label {
  position: absolute;
  left: 50%;
  bottom: 0;
  transform: translate(-50%, 50%);
  margin: 0;
  padding: 0 14px;
  background: var(--ink);
}

/* 05 — annotations under a screenshot. Numbered so a caption can point at
   a region of the image without an overlay that breaks on resize. */
.annot {
  list-style: none;
  margin: clamp(24px, 2.8vw, 34px) 0 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
  gap: clamp(20px, 2.6vw, 34px);
}
.annot__n { display: block; margin-bottom: 12px; }
.annot__t { display: block; margin-bottom: 6px; font-weight: 600; letter-spacing: -0.012em; }
.annot p { margin: 0; color: var(--mist); }

/* 06 — need on the left, capability on the right. A description list
   rather than a table: it is a set of definitions, not a data set. */
.matrix {
  display: grid;
  grid-template-columns: minmax(0, 28ch) minmax(0, 1fr);
  margin: clamp(30px, 3.6vw, 44px) 0 0;
  border-top: 1px solid var(--hair);
}
.matrix dt,
.matrix dd {
  margin: 0;
  padding: clamp(13px, 1.7vw, 18px) 0;
  border-bottom: 1px solid var(--hair);
}
.matrix dt { padding-right: clamp(14px, 3vw, 40px); color: var(--mist); }

/* 07 — a sequence read as one continuous thread. The connector runs
   down the left rather than the centre so it reads as a thread rather than
   a set of boxes that happen to be stacked. */
.chain { list-style: none; margin: clamp(30px, 3.6vw, 44px) 0 0; padding: 0; }
.chain li {
  position: relative;
  padding: clamp(11px, 1.4vw, 14px) clamp(16px, 2vw, 22px);
  border: 1px solid var(--hair);
  border-radius: 8px;
  letter-spacing: -0.012em;
}
.chain li + li { margin-top: 26px; }
.chain li + li::before {
  content: '';
  position: absolute;
  left: 30px;
  top: -27px;
  height: 27px;
  border-left: 1px solid var(--hair-strong);
}
.chain li + li::after {
  content: '';
  position: absolute;
  left: 27px;
  top: -9px;
  width: 5px;
  height: 5px;
  border-right: 1px solid var(--hair-strong);
  border-bottom: 1px solid var(--hair-strong);
  transform: rotate(45deg);
}
/* The last link is where the evidence lands, so it is the one that is lit. */
.chain li:last-child { border-color: var(--hair-strong); color: var(--paper); }
.chain--tight li + li { margin-top: 20px; }
.chain--tight li + li::before { top: -21px; height: 21px; }
.chain--tight li + li::after { top: -6px; }

/* 08 — many studies, one synthesis. The brace is three stubs meeting a
   spine, and the spine has to start and finish exactly on the first and
   last node's centre. For three equal nodes of height h separated by gap
   g, h/2 = H/6 − g/3, which is what the inset below solves for. A flat
   16.6% lands ~7px short at either end and reads as a drawing error. */
.converge {
  --brace: clamp(44px, 6vw, 84px);
  --g: clamp(14px, 2vw, 22px);
  display: grid;
  grid-template-columns: minmax(0, 1fr) var(--brace) minmax(0, 1fr);
  align-items: center;
  margin: clamp(30px, 3.6vw, 44px) 0 0;
}

.converge__in { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--g); }
.converge__in li {
  position: relative;
  padding: clamp(12px, 1.5vw, 15px) clamp(14px, 1.8vw, 18px);
  border: 1px solid var(--hair);
  border-radius: 8px;
  color: var(--mist);
  letter-spacing: -0.012em;
}
.converge__in li::after {
  content: '';
  position: absolute;
  left: 100%;
  top: 50%;
  width: calc(var(--brace) / 2);
  border-top: 1px solid var(--hair-strong);
}

.converge__brace { position: relative; align-self: stretch; }
.converge__brace::before {
  content: '';
  position: absolute;
  left: 50%;
  top: calc(16.667% - var(--g) / 3);
  bottom: calc(16.667% - var(--g) / 3);
  border-left: 1px solid var(--hair-strong);
}
.converge__brace::after {
  content: '';
  position: absolute;
  left: 50%;
  right: 0;
  top: 50%;
  border-top: 1px solid var(--hair-strong);
}

.converge__out {
  padding: clamp(16px, 2vw, 22px) clamp(16px, 2vw, 22px);
  border: 1px solid var(--hair-strong);
  border-radius: 10px;
}
.converge__out p { margin: 0; color: var(--mist); }
.converge__out .loop__name { margin-bottom: 8px; }

/* The question the repository could answer, over the one it can answer
   now. The first is dimmed rather than struck out — it was never a bad
   question, it was just the only one available. */
.swap { margin: clamp(30px, 3.6vw, 44px) 0 0; }
.swap p {
  margin: 0;
  max-width: 36ch;
  /* Was 1.6rem, which made this the second-largest editorial voice on the
     page and close enough to the .statement spine to read as a fifth one
     while scanning. It sits in the text column, not the art column, so it
     is prose rather than diagram typography. Stepped down to the lede
     tier: the device here is the dim-to-bright contrast between the two
     questions, not the size, and that survives intact. */
  font-size: clamp(1.05rem, 1.4vw, 1.32rem);
  line-height: 1.34;
  letter-spacing: -0.016em;
}
.swap p + p { margin-top: 18px; }
.swap__was { color: var(--mist); }
.swap__now { color: var(--paper); }

/* 09 — before and after, side by side. No dashboard, no invented numbers:
   the claim is that the shape of the work changed, so the evidence is a
   list of things that are true now and were not before. */
.compare {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr));
  gap: clamp(20px, 3vw, 48px);
  margin: clamp(30px, 3.6vw, 44px) 0 0;
}
.compare__col > p { margin: 0 0 16px; }
.compare ul { list-style: none; margin: 0; padding: 0; }
.compare li { padding: 12px 0; border-top: 1px solid var(--hair); }
.compare li:first-child { border-top: 0; padding-top: 0; }
.compare--was li { color: var(--mist); }
.compare--now li { color: var(--paper); }

/* AI practice — the Custom GPT's three layers. Nested boxes rather than a
   numbered list: the whole argument for that build was that the evidence
   sat *inside* two rings of instruction, and a list flattens the nesting
   back out. The outer ring is dashed because it is the defensive layer —
   it describes what the persona refuses, not what it knows. Everything
   else is borrowed from the diagram set above. */
.shells {
  margin: clamp(30px, 3.6vw, 44px) 0 0;
  padding: clamp(14px, 1.8vw, 20px);
  border: 1px dashed var(--hair-strong);
  border-radius: 14px;
}
.shells__layer {
  padding: clamp(14px, 1.8vw, 20px);
  border: 1px solid var(--hair);
  border-radius: 10px;
}
/* The centre is the only part backed by research, so it is the only part lit. */
.shells__layer--core { border-color: var(--hair-strong); }
.shells p { margin: 0; letter-spacing: -0.012em; color: var(--mist); }
.shells__head { margin: 0 0 10px; color: var(--paper); }
.shells p + .shells__layer { margin-top: clamp(14px, 1.8vw, 20px); }

/* Housekeeping line at the foot of a case study — what the screenshots are,
   who is not in them. It has to be present and findable without ever being
   read before the work, so it takes .mono's fine-print voice and sits in the
   gap between the last section and the contact block rather than inside
   either one.

   No max-width on purpose. A `ch` cap under-measures here — .mono adds
   0.16em of tracking on top of the glyph, so 70ch reserves ~460px for a
   line that actually sets ~570px and the note broke onto a second line at
   full width. The text is short enough to hold one line unaided, and still
   wraps on a phone, which is where wrapping is correct. */
.page-note { margin: clamp(30px, 3.6vw, 44px) 0 0; }

/* A cited source at the foot of a case study. The disclaimer line above is
   .mono fine-print; a full citation set in tracked uppercase is unreadable
   at that length, so this drops the mono voice and takes the same quiet
   register as a figure caption — one step down from body, --mist on --ink,
   capped at a caption measure. Sits directly under .page-note. */
.source-note {
  margin: 12px 0 0;
  max-width: 70ch;
  font-size: 0.88rem;
  line-height: 1.5;
  letter-spacing: -0.005em;
  color: var(--mist);
}
.source-note cite { font-style: italic; }

/* ------------------------------------------------------------------
   Entrance
   ------------------------------------------------------------------ */

/* Driven entirely by CSS, on the clock the stylesheet starts rather than on
   a class from site.js. The version before this one held every element on
   every page at opacity 0 until the script added .is-in to <body> — which
   made first paint wait for the whole critical chain to finish: parse the
   HTML, fetch and parse 69 KB of CSS, fetch and run 66 KB of field.js, then
   run site.js. On a throttled phone that measured 2.9 s to *any* content,
   with FCP and LCP landing on the same tick because the entire page arrived
   at once. Now the animation starts as soon as this file is parsed, so the
   headline is on screen while the scripts are still downloading.

   fill-mode is `backwards`, not `both`: it holds the opening frame through
   the delay and then hands the element back to the normal cascade. `both`
   would leave the finished animation's opacity: 1 permanently in force —
   and an animated value outranks a plain declaration, so .nova-on could
   never fade the chrome out for the supernova.

   No failsafe timer and no <noscript> override any more. Both existed to
   cover a script that never arrived, and there is no longer a script to
   wait for: scripts blocked, broken or off all land on the same path the
   healthy one takes. */
.reveal {
  animation: reveal-in 900ms var(--ease) var(--delay, 0ms) backwards;
}

@keyframes reveal-in {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: none; }
}

/* The stagger, which site.js used to compute as index × 90ms over every
   .reveal on the page. Hand-set here, and only for the things that are
   above the fold on arrival — those are the only ones whose entrance is
   ever actually watched. Everything further down keeps --delay at 0 and
   comes in with the first group, which is also a fix: on a long case study
   the old rule was handing element thirty a 2.7 s delay, so a fast scroll
   arrived at blank space that had not been told to appear yet. */
.masthead__name { --delay: 0ms; }
.masthead__nav  { --delay: 90ms; }

.hero__title { --delay: 180ms; }
.hero__meta  { --delay: 270ms; }
.colophon    { --delay: 360ms; }

/* By class and not by :nth-child. The interior field appends its two
   canvases into .page__head, so a positional selector matches one set of
   elements at first paint and a different set the moment field.js runs —
   which is the same "the entrance depends on a script" bug this section
   just removed, wearing a different hat. */
.page__head > .mono { --delay: 180ms; }
.page__title        { --delay: 270ms; }
.page__lede         { --delay: 360ms; }

/* ------------------------------------------------------------------
   Responsive
   ------------------------------------------------------------------ */

/* The framework loop stacks earlier than everything else. Five columns in
   a 900px window leaves each card ~165px wide and five lines tall, which
   reads as a cramped column rather than a cycle. Below 1040px the row
   becomes a stack: the connectors turn to point down, and the return rail
   becomes a rule, since a bracket wrapping a screen-tall stack stops
   reading as a return at all. */
@media (max-width: 1040px) {
  .loop__stages { grid-template-columns: 1fr; }
  .loop__stage + .loop__stage::before {
    top: calc(-1px - var(--g));
    left: 50%;
    width: 0;
    height: var(--g);
    border-top: 0;
    border-left: 1px solid var(--hair-strong);
  }
  .loop__stage + .loop__stage::after {
    top: calc(1px - var(--g));
    left: 50%;
    transform: translateX(-50%) rotate(135deg);
  }
  .loop__return {
    height: auto;
    margin: 20px 0 0;
    padding-top: 16px;
    border: 0;
    border-top: 1px dashed var(--hair-strong);
    border-radius: 0;
  }
  .loop__return::before { display: none; }
  .loop__label { position: static; transform: none; padding: 0; background: none; }
}

/* The same stacked loop, reached the other way. The rules above are a
   viewport query, and a viewport query stopped describing this component the
   moment its row went narrow: a narrow row's body is the 68ch measure —
   about 590px — however wide the window is, so at 1440px the five stages
   were still trying to hold five columns of 103px each. Restated against the
   row. Duplication rather than a container query, because converting one
   component to @container while every neighbouring diagram still asks about
   the viewport would leave two models in the same stylesheet. */
.row:not(.row--wide) .loop__stages { grid-template-columns: 1fr; }
.row:not(.row--wide) .loop__stage + .loop__stage::before {
  top: calc(-1px - var(--g));
  left: 50%;
  width: 0;
  height: var(--g);
  border-top: 0;
  border-left: 1px solid var(--hair-strong);
}
.row:not(.row--wide) .loop__stage + .loop__stage::after {
  top: calc(1px - var(--g));
  left: 50%;
  transform: translateX(-50%) rotate(135deg);
}
.row:not(.row--wide) .loop__return {
  height: auto;
  margin: 20px 0 0;
  padding-top: 16px;
  border: 0;
  border-top: 1px dashed var(--hair-strong);
  border-radius: 0;
}
.row:not(.row--wide) .loop__return::before { display: none; }
.row:not(.row--wide) .loop__label { position: static; transform: none; padding: 0; background: none; }

@media (max-width: 900px) {
  .row { grid-template-columns: 1fr; }
  .row__label { padding-top: 0; }

  /* Case-study diagrams. Every one of them is a row on desktop and a stack
     here; what changes is which edge the connectors run along. Where a
     connector would have to be redrawn rather than reoriented, it is
     dropped and the label carries the meaning on its own. */

}

/* Diagram internals hold their multi-column form well below the point
   where the page itself reflows. A tablet at 768px has ~684px of body
   once the columns stack — plenty for three branches or three artifact
   piles — so collapsing these at 900px turned tablets into a very long
   phone. They go single-column only when a column genuinely stops fitting. */
@media (max-width: 640px) {
  .scatter__cols { grid-template-columns: 1fr; gap: clamp(18px, 4vw, 26px); }
  .scatter__col { padding-bottom: 0; }
  .scatter__col::after { display: none; }
  .scatter__funnel { height: 40px; }
  .scatter__funnel::before { display: none; }

  .matrix { grid-template-columns: 1fr; }
  .matrix dt {
    padding: 18px 0 0;
    border-bottom: 0;
    font-family: var(--mono);
    font-size: 10px;
    letter-spacing: 0.14em;
    text-transform: uppercase;
  }
  .matrix dd { padding-top: 7px; }

  .converge { grid-template-columns: 1fr; }
  .converge__in li::after { display: none; }
  .converge__brace { height: 38px; }
  .converge__brace::before { top: 0; bottom: 0; }
  .converge__brace::after { display: none; }
}

@media (max-width: 560px) {
  :root { --bar-h: 54px; }

  .trail { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .masthead { flex-direction: column; align-items: flex-start; gap: 14px; }

  /* The landing's masthead is free to stack; a fixed bar is not — two rows
     of it would eat a sixth of a phone screen on every interior page. It
     stays one row and the pieces tighten up to fit. */
  .page .masthead {
    flex-direction: row;
    align-items: center;
    gap: 10px;
  }
  .page .masthead__name { font-size: 12px; letter-spacing: 0; }
  .page .masthead__nav { gap: 13px; }
  .page .masthead__nav a { letter-spacing: 0.08em; }

  .to-top { width: 42px; height: 42px; }
  .hero__title { letter-spacing: -0.034em; }
  .colophon { flex-direction: column; align-items: flex-start; }
}

@media (prefers-reduced-motion: reduce) {
  * { animation: none !important; transition: none !important; }
  .reveal { opacity: 1; transform: none; }
  /* The field renders one static frame and never repels, so the headlines
     must not depend on a cursor they will never see. */
  .field-ready .hero__title__layer { color: var(--paper); }
  .hero__title__layer--lit { display: none; }
  .field-ready .page__title__layer { color: var(--paper); }
  .page__title__layer--lit { display: none; }
}
