/* ══════════════════════════════════════════════════════════════════════════
   Paper pages — shared stylesheet (PRD.md §9c)

   Everything the paper-side pages have in common: the module system that
   dresses the tokens in css/tokens.css, and the chrome that is the same on
   every one of them — the upstream ripple, the leaving wash, and the way
   media arrives.

   It is loaded by work.html and by about.html, and it exists because
   those two are the same object with different content: a stack of
   <section class="pm pm-*"> modules on paper, reached from index.html and
   left by the corner ripple. Anything only one page has stays inline in
   that page — work.html's WORK button back to the shelf is the whole of
   that list.

   Content is js/projects.js (work) and js/about.js (About); the DOM is
   js/modules.js plus a per-page renderer. No page writes module markup by
   hand.
   ══════════════════════════════════════════════════════════════════════════ */

/* Design tokens are css/tokens.css, loaded ahead of this sheet by both pages
   that use it. Nothing here declares one. */

* { margin: 0; padding: 0; box-sizing: border-box; }

html {
  /* A permanently reserved scrollbar gutter: the nav icon's alignment is
     built on it.

     A reserved gutter comes out of the initial containing block and `vw`
     measures the ICB, so `100vw` here is 15px short of the window, where on
     index.html — no scrollbar, no gutter — it is the full width. The two
     pages' nav icon has to land on the same screen coordinate or it steps
     sideways on every navigation between them, so index.html measures the
     difference and subtracts it: see --sbw in js/shelf.js and .up-level.
     Taking the gutter out means taking that repair out with it, and every
     `vw` in this page's module layout moves by a pixel and a half. */
  scrollbar-gutter: stable;
}

body {
  background-color: var(--paper);
  color: var(--ink);
  font-family: var(--font);
  font-size: var(--body-size);
  font-weight: 500;
  line-height: 1.3;
  -webkit-font-smoothing: antialiased;
  /* wash runs a touch longer than the content settle so the pine arrives
     as its own completed beat before navigation (nav fires at wash + 40).
     index.html's veil opens on this exact colour and the swap between the
     two is suppressed there, so this does not have to be a beat the visitor
     waits out — it only has to get the paper off the screen. */
  transition: background-color 400ms cubic-bezier(0.4, 0, 0.2, 1);
}

/* ── Type styles ───────────────────────────────────────────────────────
   One class per token. Nothing else on the page sets a size or a weight —
   if a style is needed that is not here, it belongs in css/tokens.css
   first. */
.type-display { font-weight: 900; font-size: var(--display-size); line-height: 1.2; text-wrap: balance; }
.type-heading { font-weight: 700; font-size: var(--heading-size); line-height: 1.2; }
.type-body    { font-weight: 500; font-size: var(--body-size);    line-height: 1.3; text-wrap: pretty; }
.type-caption { font-weight: 600; font-size: var(--caption-size); line-height: 1.3; }

/* The expressive display — the token sheet's second display style, and the
   only place on the site that leaves Urbanist. It is about.html's, where a
   serif is the difference between a headline about fishing and a headline
   about a client: the work pages state, this one speaks.

   A modifier rather than a class of its own, on the .chip--solid idiom, so
   what it overrides is exactly what differs — family, weight, size — and the
   120% leading, the balanced wrap and any measure a layout puts on
   .type-display keep applying. Which means it has to sit after .type-display
   to win on source order; both are one class. */
.type-display--expressive {
  font-family: var(--font-expressive);
  font-weight: 700;
  font-size: var(--display-expressive-size);
}

/* ── Chips ─────────────────────────────────────────────────────────────
   Skill tags on a project. Outline by default; .chip--solid is the token
   sheet's dark-mode pair, for a project whose `bg` is dark enough that an
   outline on it would vanish.

   Both run on --paper/--ink rather than on a fixed colour, so a chip is in
   the project's own colourway wherever it lands. */
.chips { display: flex; flex-wrap: wrap; gap: 8px; list-style: none; }
.chip {
  font-weight: 700;
  font-size: var(--chip-size);
  line-height: 1;
  padding: 6px 12px;
  border-radius: 30px;
  color: var(--ink);
  border: 1px solid var(--ink);
  white-space: nowrap;
}
.chip--solid { background: var(--ink); border-color: var(--ink); color: var(--paper); }

/* ══════════════════════════════════════════════════════════════════════
   Module system

   Every module is a <section class="pm pm-*"> and every one of them carries
   3rem beneath it. `.pm--flush` takes that away, per module, for the pairs
   that are meant to meet — an editorial run where two images butt is a
   deliberate effect, and the escape hatch is what keeps it available now
   that air is the default rather than the exception.
   ══════════════════════════════════════════════════════════════════════ */
.pm { position: relative; padding-bottom: var(--spacer); }
.pm--flush { padding-bottom: 0; }

.pm-figure { position: relative; margin: 0; }
/* The frame owns the aspect ratio, so the box is reserved before a byte of
   image arrives and nothing on the page reflows as media decodes. */
.pm-frame {
  position: relative;
  overflow: hidden;
  background: var(--tone, var(--ph-fill));
}
/* <picture> is a selection wrapper only — it must not put a line box
   between the frame and the fill it positions. */
.pm-frame picture { display: contents; }
.pm-img,
.pm-clip {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
  object-fit: cover;
  /* The art direction. A cover crop pivots around this, which is the whole
     reason a landscape frame can be dropped into a portrait viewport and
     still keep its subject. */
  object-position: var(--focus, 50% 50%);
}
/* The clip sits on its own still, and fades up only once it is actually
   playing (js/modules.js sets --playing on the first `playing` event). Until
   then the poster is what is on screen, which is why the transparent state is
   the default rather than something JS has to remember to apply. */
.pm-clip {
  opacity: 0;
  transition: opacity 700ms ease;
}
.pm-clip--playing { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  /* Belt and braces: js/modules.js never attaches a clip under reduced
     motion, so this only catches one that somehow got built anyway. */
  .pm-clip { display: none; }
}
/* Captions. Opt-in per image and deliberately not a default: the stack is
   meant to read as pictures, not as a catalogue. Bottom-left of the image
   it belongs to, one --cap-pad away and no further. */
.pm-cap {
  padding-top: var(--cap-pad);
  max-width: 54ch;
  font-weight: 600;
  font-size: var(--caption-size);
  line-height: 1.3;
  color: var(--ink);
  opacity: 0.66;
}

/* ── Full bleed ────────────────────────────────────────────────────────
   100% of the viewport in both axes. Width is 100%, not 100vw: this page
   scrolls, so 100vw is a scrollbar wider than the content box and would
   buy a horizontal scrollbar to go with it. */
.pm-full .pm-frame { width: 100%; height: 100svh; }
/* Nowhere below the image to put a caption, so it goes on it — same
   bottom-left corner, same offset, reversed out. */
.pm-full .pm-cap {
  position: absolute;
  left: var(--cap-pad);
  bottom: var(--cap-pad);
  padding-top: 0;
  color: var(--cream);
  opacity: 1;
  text-shadow: 0 1px 10px rgba(20, 20, 15, 0.55);
}

/* ── Equal fixed ───────────────────────────────────────────────────────
   50/50, both panes a fixed 4:5 — product screens and portraits, which is
   why the ratio is fixed rather than taken from the media. */
.pm-equal .pm-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--mod-gap);
  padding: 0 var(--mod-inset);
}
.pm-equal .pm-frame { aspect-ratio: 4 / 5; }

/* ── Editorial inset ───────────────────────────────────────────────────
   One large 4:5 bled off a page edge, one smaller 4:5 floating in the
   space left over. `--right`/`--left` name where the *inset* sits, which
   is how the comps are labelled. The large image is placed by the grid,
   not by a negative margin, so the two variants are one rule apart. */
.pm-ed .pm-grid { display: grid; align-items: center; }
.pm-ed .pm-frame { aspect-ratio: 4 / 5; }

/* ── Single image ──────────────────────────────────────────────────────
   Fixed 4:3, inset both sides. */
.pm-single .pm-body { padding: 0 var(--mod-inset); }
.pm-single .pm-frame { aspect-ratio: 4 / 3; }

/* Square: 1:1 at 75% of the column, which is the 4:3's own height, so it
   sits under a 4:3 single without reading as a bigger beat. Full column on
   a phone, where a square is shorter than the screen anyway. */
.pm-single--square .pm-frame { aspect-ratio: 1 / 1; }
@media (min-width: 768px) {
  .pm-single--square .pm-figure { width: 75%; margin: 0 auto; }
}

/* ── Text ──────────────────────────────────────────────────────────────
   Copy alone, mid-stack. On desktop it starts on the equal module's right
   column, a line the image modules and the intro's copy share at every
   window size. Same measure and paragraph gap as the intro copy. */
.pm-text .pm-body { padding: 0 var(--mod-inset); }
.pm-text .type-body { max-width: 46ch; }
.pm-text .type-body + .type-body { margin-top: clamp(14px, 1.6vw, 20px); }
@media (min-width: 768px) {
  .pm-text .pm-body {
    /* Inset, one equal column, one gap. 100% is the section's width, the
       same box .pm-equal .pm-grid divides. */
    padding-left: calc(var(--mod-inset)
      + (100% - 2 * var(--mod-inset) - var(--mod-gap)) / 2
      + var(--mod-gap));
  }
}

/* ── Intro ─────────────────────────────────────────────────────────────
   The top of every project. The lead frame is what the view transition
   arrives on — js/project.js names it `lead-in`, and the ::view-transition
   rules at the foot of this file say what that means coming from the shelf
   (a morph out of the tapped tile); from anywhere else it is the build-in.
   Either way the background washes to the project's colour and the copy
   rises in behind it.

   js/modules.js's `intro` builds the same section, with the lead or without
   it — see `.pm-intro--quiet` below for what the grid does when there is no
   picture. It still holds, because a one-child grid is a stack. */
/* The intro's two measures. --intro-gap is the stacked (phone) gap; on
   desktop the columns take --mod-gap. --intro-lead is the lead's height
   cap, applied as a width. */
:root {
  --intro-gap: clamp(28px, 4vw, 50px);
  --intro-lead: 59svh;
}
.pm-intro {
  display: grid;
  gap: var(--intro-gap);
  /* padding-inline, not the shorthand: `padding: 0 X` would reset the
     block axis and take .pm's 3rem off the bottom with it. */
  padding-inline: var(--mod-inset);
}
.pm-intro .pm-frame { aspect-ratio: 4 / 5; }
.pm-intro-copy > * + * { margin-top: clamp(14px, 1.6vw, 20px); }
.pm-intro-copy .chips { margin-top: clamp(20px, 2.4vw, 30px); }
.pm-intro-copy .type-body { max-width: 46ch; }

/* Mobile: copy first, lead under it. DOM order is lead-first — it is the
   page's first paint target and the thing the transition is morphing —
   so the two are reordered here rather than in the renderer. */
@media (max-width: 767px) {
  .pm-intro { padding-top: clamp(5.5rem, 15vh, 9rem); }
  .pm-intro .pm-lead { order: 2; }
  .pm-intro-copy { order: 1; }
  /* Stacked panes want more air between them than the side inset. */
  .pm-equal .pm-grid { grid-template-columns: 1fr; gap: clamp(24px, 8vw, 46px); }
  /* The large pane keeps its bleed; the inset comes back to a centred 76%
     so it still reads as the smaller of the two. */
  .pm-ed .pm-inset {
    width: 76%;
    justify-self: center;
    margin-top: clamp(24px, 7vw, 56px);
  }
}

@media (min-width: 768px) {
  .pm-intro {
    /* The equal module's two columns, so the copy starts on the same line
       as .pm-text and every 50/50 pane's right half, at any window height. */
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    column-gap: var(--mod-gap);
    align-items: center;
    min-height: 100svh;
    padding-block: clamp(4rem, 10vh, 8rem);
  }
  .pm-intro-copy { grid-column: 2; }
  /* The lead fills its column until the window gets short. A 4:5 frame
     41vw across is 51.25vw tall, which on a 1920x800 display would push the
     copy off the screen it is supposed to share; 59svh of width is 74svh of
     height, the comp's proportion, held. The lead shrinks inside its column
     rather than the column shrinking, so the copy never moves: it keeps the
     left margin and the extra room opens up between picture and copy. */
  .pm-intro .pm-lead { grid-column: 1; width: min(100%, var(--intro-lead)); }

  /* 52vw large + a 32vw inset centred in what is left. Both measured off
     the comps; the large one carries the module's height. */
  .pm-ed--right .pm-grid { grid-template-columns: 52vw minmax(0, 1fr); }
  .pm-ed--left  .pm-grid { grid-template-columns: minmax(0, 1fr) 52vw; }
  .pm-ed--left .pm-large { grid-column: 2; grid-row: 1; }
  .pm-ed--left .pm-inset { grid-column: 1; grid-row: 1; }
  .pm-ed .pm-inset { width: 32vw; justify-self: center; }
}

/* ── Intro, quiet ──────────────────────────────────────────────────────
   The intro module built with no `lead` (js/modules.js `intro`). Two things
   change and both follow from the missing picture: the grid collapses to one
   column — the desktop rule above puts the copy in column 2, which with
   nothing in column 1 would indent it off a 39vw void — and the heading
   takes a measure, because a 64px display line run the full width of the
   page is a banner, and a banner is the opposite of quiet.

   It also stops being a full screen. An intro with a lead reserves 100svh
   because it has a picture to hold up; copy alone does not, so this is a
   held breath between two modules rather than a title card. What reaches it
   today is about.html's <noscript> block, which has no renderer to build
   a lead with — the page itself takes the two-column form. Placed after both
   breakpoint blocks so it wins on source order at equal specificity — the
   same reason .pm--flush sits below .pm. */
.pm-intro--quiet {
  grid-template-columns: minmax(0, 1fr);
  min-height: 0;
  padding-block: clamp(4rem, 14vh, 9rem);
}
.pm-intro--quiet .pm-intro-copy { grid-column: 1; }
.pm-intro--quiet .type-display { max-width: 22ch; }

/* ── Upstream ripple ───────────────────────────────────────────────────
   Fixed, top-right, same 4rem hit-area / 3rem glyph size as the wade-in
   icon on index.html — matched view-transition-name lets the two morph
   into each other during the cross-document navigation, so this has to sit
   exactly where .wade sits. The box is inset half a rem past the gutter so
   the 3rem glyph, not its padding, lands on the margin. */
.upstream {
  position: fixed;
  top: max(1.125rem, env(safe-area-inset-top));
  /* Measured from 100vw so it lands on the same screen coordinate as
     index.html's .up-level, which subtracts the gutter this page reserves
     (see the `html` rule above). Keep the two in lockstep. */
  left: calc(100vw - 4rem - var(--slot-inset));
  z-index: 6;
  width: 4rem;
  height: 4rem;
  display: grid;
  place-items: center;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  view-transition-name: nav-icon;
  transition: opacity 300ms ease;
}
.upstream:focus-visible { outline: 1px solid var(--ink); outline-offset: 0.25rem; border-radius: 50%; }
.upstream svg {
  width: 3rem;
  height: auto;
  overflow: visible;
  filter: drop-shadow(0 1px 8px rgba(20, 20, 15, 0.18));
  transition: transform 400ms cubic-bezier(0.22, 0.9, 0.3, 1);
}
.upstream:hover svg, .upstream:focus-visible svg { transform: scale(1.08); }
/* tap: the surface takes the touch. Each of the ripple's three rings is
   drawn as two arcs — a top crest and a bottom crest — and the exported
   paths are not in concentric order: measured off the artwork, document
   order runs outer-top, inner-top, inner-bottom, outer-bottom, mid-bottom,
   mid-top. --r regroups them innermost (0) to outermost (2) so the dip
   travels outward from the touch point, and --dip attenuates with distance
   the way a real depression does. Both arcs of a ring share a delay and an
   amplitude, or the ring stops reading as a ring. */
/* The glyph's colour. Pine, and it is the one piece of chrome that keeps a
   colour. Ink is too heavy against paper: this is a small drawing of water sitting on
   a light page, and at near-black it stops reading as a ripple and starts
   reading as a glyph. Pine is also what it is a way back to.

   The transition is what lets about.html crossfade the fill to cream over
   its opener instead of snapping; that override is
   `body.over-opener .upstream svg .cls-1` and it outranks this on plain
   specificity. */
.upstream svg .cls-1 {
  fill: var(--pine);
  transition: fill 480ms cubic-bezier(0.4, 0, 0.2, 1);
  transform-box: view-box;
  transform-origin: 50% 50%;
}
.upstream svg .cls-1:nth-of-type(2),
.upstream svg .cls-1:nth-of-type(3) { --r: 0; --dip: 7px; }
.upstream svg .cls-1:nth-of-type(6),
.upstream svg .cls-1:nth-of-type(5) { --r: 1; --dip: 5px; }
.upstream svg .cls-1:nth-of-type(1),
.upstream svg .cls-1:nth-of-type(4) { --r: 2; --dip: 3px; }
.upstream.go .cls-1 {
  animation: ring-dip 320ms both;
  animation-delay: calc(var(--r, 0) * 40ms);
}
/* The surface gives under the touch, surface tension snaps it back past
   rest, then it releases outward into the wash. The per-keyframe easings
   carry the spring — the 30% one overshoots the rebound and settles back.
   320ms + the outer ring's 80ms delay lands the whole thing on WASH_MS. */
@keyframes ring-dip {
  0% {
    transform: translateY(0) scale(1);
    opacity: 1;
    animation-timing-function: cubic-bezier(0.3, 0, 0.3, 1);
  }
  30% {
    transform: translateY(var(--dip, 5px)) scale(0.94);
    opacity: 1;
    animation-timing-function: cubic-bezier(0.3, 1.7, 0.5, 1);
  }
  64% {
    transform: translateY(calc(var(--dip, 5px) * -0.34)) scale(1.05);
    opacity: 1;
    animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
  }
  100% { transform: translateY(0) scale(1.1); opacity: 0; }
}

/* Leaving for index.html: content settles down and fades while the page
   washes to the pine colorway as the ripple takes the touch. index.html
   is prerendered in the background (see each page's speculationrules) so
   the navigation itself is instant rather than a second visible beat. */
body.leaving { background-color: var(--pine); }
/* `.page-stack` and not the <main> element or work.html's `#project` id:
   the class is the contract every paper page opts into, and it is what
   js/upstream.js's wash is animating out. */
body.leaving .page-stack,
body.leaving .site-footer {
  opacity: 0;
  transform: translateY(1.5rem);
  pointer-events: none;
}
.page-stack,
.site-footer { transition: opacity 320ms ease, transform 320ms cubic-bezier(0.4, 0, 0.2, 1); }

/* ── Footer links ──────────────────────────────────────────────────────
   The band under every case study and about.html — every paper page, and
   not index.html, which has no foot to put it on. Cream on ink: the
   chrome pair, so it reads as the frame around the work and not as more of
   it. The page you are on is marked by tone and a leading underscore, a
   cursor waiting at the line, rather than by colour.

   Desktop runs the names inline with a slash between them and lets them
   wrap wherever the measure falls, mid-title included; a phone stacks them.
   The slashes are generated and hidden from assistive tech — they are
   punctuation for the eye; the list is already the structure.

   The gap above it is --footer-pad less the --spacer every module already
   carries beneath it, so the air above the band matches the air inside it. */
.site-footer {
  margin-top: calc(var(--footer-pad) - var(--spacer));
  padding: var(--footer-pad) var(--mod-inset);
  padding-bottom: max(var(--footer-pad), env(safe-area-inset-bottom));
  background: var(--ink);
  color: var(--cream);
}
.site-footer ul {
  list-style: none;
  font-weight: 700;
  font-size: var(--footer-links-size);
  line-height: 1.3;
  letter-spacing: 0;
}
.site-footer a {
  color: var(--cream);
  text-decoration: none;
  -webkit-tap-highlight-color: transparent;
  touch-action: manipulation; /* taps go straight through; see js/footer.js */
}
.site-footer a[aria-current="page"] { color: #999; }
.site-footer a[aria-current="page"]::before { content: "_"; content: "_" / ""; }
@media (hover: hover) {
  .site-footer a:hover { color: var(--tan); }
}
.site-footer a:active { color: var(--tan); }
.site-footer a:focus-visible { outline: 2px solid var(--cream); outline-offset: 3px; }
@media (min-width: 768px) {
  .site-footer li { display: inline; }
  .site-footer li:not(:last-child)::after { content: " /"; content: " /" / ""; }
}

/* ── The lead's build-in ───────────────────────────────────────────────
   A cold load is the only time this runs. Arriving from the shelf, the
   view transition is already the entrance — and a fade running
   underneath the shelf's morph makes the picture drop out mid-morph and
   climb back — so the renderer only sets these classes when the referrer
   says nothing transitioned into this page.

   Two states, and the split is a safety property rather than a nicety.
   `--pending` is set during render, before the first paint, and does
   nothing but hold the picture back. `--arriving` replaces it once the
   image has loaded. Everything that can go wrong — an image that never
   arrives, JS erroring out after render — ends with `--pending` being
   dropped on a timer and the picture simply visible, because the failure
   mode of an opacity trick has to be a picture and never a blank frame.

   Waiting on the image rather than on the document is the half that
   matters on a real connection. The frame's own fill holds the space in
   the meantime, so what you get is a picture surfacing rather than a
   picture appearing at whatever moment the bytes happened to land. */
.pm-lead--pending .pm-img { opacity: 0; }
.pm-lead--arriving .pm-img {
  /* The settle is the lead's alone — see --build-scale below. */
  --build-scale: 1.03;
  animation: media-build 1000ms cubic-bezier(0.22, 0.9, 0.3, 1) both;
}

/* Everything under the lead builds as it scrolls into view. Same keyframe
   and the same 1000ms, so the page has one way of arriving — what differs
   is what moves and what triggers it.

   The figure arrives whole, frame and caption together, rather than the
   picture surfacing over its own fill the way the lead does. Below the
   fold there is no loading gap to cover: js/modules.js waits for the image
   before it starts, so by the time anything animates the picture is there.

   And no settle. The lead arrives into a page that is standing still, so a
   little scale reads as breathing; these arrive because you moved, and the
   scroll is already supplying that motion. Scaling a 100vw full-bleed
   figure would also push 3% past the viewport and buy a horizontal
   scrollbar for the length of the animation. */
.pm-figure--pending { opacity: 0; }
.pm-figure--arriving {
  animation: media-build 1000ms cubic-bezier(0.22, 0.9, 0.3, 1) both;
}

/* One keyframe for both. --build-scale defaults to 1, so the transform is
   an identity for everything that does not ask for it — scale, not
   translate, because the lead's frame clips: the picture settles inside a
   box that never moves, where a rise would drag the fill with it or open a
   gap at the edge. */
@keyframes media-build {
  from { opacity: 0; transform: scale(var(--build-scale, 1)); }
  to   { opacity: 1; transform: none; }
}

/* ── Entry motion ──────────────────────────────────────────────────────
   The copy. It rises over the lead rather than with it: the picture is the
   ground and starts first, the words land on top of it. */
.flow {
  opacity: 0;
  animation: flow-in 620ms cubic-bezier(0.22, 0.9, 0.3, 1) forwards;
  /* --flow-base defaults to 0ms, so this is the default delay
     everywhere. A spoken heading sets it, because copy under one of those has
     a much longer thing to wait for than 90ms. */
  animation-delay: calc(var(--flow-base, 0ms) + var(--i, 0) * 90ms);
}
@keyframes flow-in {
  from { opacity: 0; transform: translateY(12px); }
  to { opacity: 1; transform: none; }
}

/* ── The spoken heading ────────────────────────────────────────────────
   About's headline only. .type-display--expressive above says why that
   page leaves Urbanist — "the work pages state, this one speaks" — and this
   is the other half of that sentence. A block sliding in from an offset has
   a direction and a destination; a line being said has neither. So nothing
   here moves. The phrases come into focus.

   Blur rather than a rise because the page is photographs, and a lens
   finding focus is the motion it already contains. js/modules.js splits the
   line on breath marks the copy itself chooses.

   inline-block + nowrap is what makes a phrase atomic: the line still wraps
   wherever the width demands, but it can only ever break between phrases,
   never through one. .type-display's `text-wrap: balance` still applies and
   now balances whole phrases. */
.pm-intro-copy--spoken {
  --speak-lead: 100ms;   /* the beat before the first phrase */
  --speak-step: 200ms;   /* the cadence — short enough that phrases overlap */
  --speak-dur: 480ms;    /* how softly each one surfaces */

  /* Anything below the heading waits for the last phrase to begin, rather
     than arriving while the line is still being said. */
  --flow-base: calc(var(--speak-lead) + var(--n, 0) * var(--speak-step));
}

/* Desktop measure. The copy column keeps widening with the window, and past
   ~1700px "& quiet moments" and "in the mountains." both fit on one line,
   which leaves a two-line rag of 334px over 663px. 12em holds the widest
   pair of phrases apart at any width — three lines, each roughly a third
   of the way to the next — and is wider than any single phrase. */
@media (min-width: 768px) {
  .pm-intro-copy--spoken .type-display { max-width: 12em; }
}

.spoken-phrase {
  display: inline-block;
  white-space: nowrap;

  /* `backwards`, not `forwards`, and both halves of that matter. The resting
     style of a phrase is its finished style — opaque, with no filter declared
     at all — so backwards fill holds the `from` frame through the delay and
     then hands the element back to its own rules. Nothing is left on a
     composited layer afterwards, where a held blur(0) can still re-rasterise
     type; and the failure mode of the whole trick is a legible headline,
     which is the argument the lead's --pending state makes above.

     Off the house curve deliberately. cubic-bezier(0.22, 0.9, 0.3, 1) is 90%
     done in the first fifth of its duration — it snaps, and the snap is what
     this is replacing. A soft attack and a long tail instead: a phrase
     becoming audible rather than landing. */
  animation: speak-in var(--speak-dur) cubic-bezier(0.4, 0, 0.35, 1) backwards;
  animation-delay: calc(var(--speak-lead) + var(--i, 0) * var(--speak-step));
}
@keyframes speak-in {
  from { opacity: 0; filter: blur(5px); }
  to { opacity: 1; filter: blur(0); }
}

@media (prefers-reduced-motion: reduce) {
  .flow { animation: none; opacity: 1; }
  /* No opacity reset needed, unlike .flow: a phrase's resting style is
     already the visible one. That is what backwards fill bought. */
  .spoken-phrase { animation: none; }
  .upstream svg { transition: none; }
  .upstream.go .cls-1 { animation: none; }
  .pm-lead--pending .pm-img { opacity: 1; }
  .pm-lead--arriving .pm-img { animation: none; }
  .pm-figure--pending { opacity: 1; }
  .pm-figure--arriving { animation: none; }
}

/* Paper has no scroll to reveal anything with, and an observer that never
   fires would print a page of blank frames. Same argument as the
   reduced-motion block: whatever suppresses the motion has to leave the
   pictures behind. */
@media print {
  .pm-lead--pending .pm-img { opacity: 1; }
  .pm-figure--pending { opacity: 1; }
  .pm-lead--arriving .pm-img, .pm-figure--arriving { animation: none; }
  /* Safe by construction — see the reduced-motion note — but a print that
     snapshots mid-delay would still catch the `from` frame. */
  .spoken-phrase { animation: none; }
}

/* ── The no-JS contact form ────────────────────────────────────────────
   Both <noscript> blocks carry one. A form has no address in it, so there is
   nothing in the HTML for a crawler to lift: functions/api/contact.js owns
   CONTACT_TO and knows where the message goes.

   It posts the same field names the scripted card does, and the Pages
   Function answers it in HTML, because this submit lands the visitor on the
   response itself rather than reading it from a fetch. */
.noscript-form {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 8px;
  max-width: 26rem;
  margin-top: 1.25rem;
}
.noscript-field {
  width: 100%;
  font: inherit;
  font-weight: 500;
  font-size: var(--body-size);
  color: var(--ink);
  background: transparent;
  border: 1px solid rgba(20, 20, 15, 0.25);
  border-radius: 2px;
  padding: 10px 12px;
}
textarea.noscript-field { resize: none; }
.noscript-field:focus-visible { outline: 2px solid var(--ink); outline-offset: 1px; }
.noscript-send {
  font: inherit;
  font-weight: 700;
  font-size: var(--nav-size);
  color: var(--paper);
  background: var(--ink);
  border: 0;
  border-radius: 2px;
  padding: 11px 22px;
  cursor: pointer;
}
/* Off-screen rather than display:none — a field the layout has removed is one
   some bots know to skip. */
.noscript-trap {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  opacity: 0;
}

@view-transition { navigation: auto; }

/* ── The lead's arrival ────────────────────────────────────────────────
   One name, `lead-in`, on both the tapped shelf tile (js/shelf.js) and this
   page's lead frame (js/project.js) — so the pseudo-elements are
   selectable, which a per-slug name never was. The lead is only named when
   the shelf brought you, so this is one arrival: the tile is named too, the
   pair morphs, and the tile scales into the lead's box. It works because a
   tile is already most of the size of what it becomes. Every other arrival
   leaves the lead unnamed and uses .pm-lead--arriving's build-in instead.

   Overriding duration and easing rather than `animation` keeps the UA's own
   keyframes and only re-times them. 420ms on the house curve. */
::view-transition-group(lead-in),
::view-transition-old(lead-in),
::view-transition-new(lead-in) {
  animation-duration: 420ms;
  animation-timing-function: cubic-bezier(0.22, 0.9, 0.3, 1);
}
