/* ============================================================
   Ink & Intuition — Approach
   Canonical "Three mechanisms of change" scroll scene.
   The page-wide Lenis/GSAP setup is owned by motion.js; this
   section only creates its one desktop pin in mechanisms.js.
   ============================================================ */

.approach {
  --approach-fill: 0%;
  /* The same quantity as --approach-fill, unitless, published by mechanisms.js
     alongside it. Both exist because they are consumed by different property
     types: --approach-fill is a LENGTH-ish percentage for clip-path and top,
     --approach-fill-n is a NUMBER for opacity, and calc() cannot mix a
     <number> with a <percentage> in one sum. One state channel, two spellings;
     nothing reads the fill from anywhere else. */
  --approach-fill-n: 0;
  --approach-accent: #7b6fd0;
  --approach-accent-rgb: 123, 111, 208;
  position: relative;
  padding-block: 0;
  /* Deliberately not isolated: the grain below uses mix-blend-mode and needs
     the page's paper as its backdrop. An isolated context with no background
     of its own gives it transparency to blend against, which reads as a grey
     veil over the section. .content-sheet contains the blending. */
}

/* This section paints no paper of its own. It sits on the single .content-sheet
   surface, at the same stock, scale and tone as About Jess, so the two read as
   one continuous sheet rather than two crops of the same photograph at
   different densities. Only the grain whisper is local, and it matches the
   sitewide finish: ONE layer, the shared tile at --grain-size, --grain-opacity
   and soft-light, over the whole section. tools/grain-consistency.mjs holds
   every one of those to the sitewide value.

   The one thing here that is not boilerplate is --approach-grain-y.

   Reported 2026-07-26: "the grain texture you overlayed doesn't pin like the
   paper does during the brain fill up animation." It did not, and the reason is
   structural. .approach-paper is a child of .approach-pin-wrap, so ScrollTrigger
   pins it and its fibre correctly stands still; this pseudo-element belongs to
   the SECTION, outside that wrapper, so it kept travelling with the page. For
   the length of the pin the fibre held and the grain slid across it.

   The tile is 180px and repeating, so it is periodic in exactly the way the
   paper is, and the fix is the technique js/seams.js and .approach-paper
   already use: a published phase offset fed to background-position. The one
   difference, and it is the whole difference, is that this element is NOT
   fixed during the pin, so its offset cannot be FROZEN the way the paper's is.
   It has to track. mechanisms.js re-derives it on every scrolled frame the pin
   holds, maintaining a single invariant:

       the grain's tile origin sits on .approach-pin-wrap's top edge, always.

   Outside the pin the wrapper travels with the document, so the grain travels
   with the document and nothing about the section's finish changes. Under the
   pin the wrapper is fixed at viewport row 0, so the grain's origin is pinned
   to viewport row 0 and it holds the same still frame the paper does. The value
   is continuous across both boundaries, so there is no phase jump to see at
   engage or release. tools/approach-grain-pin.mjs is the regression check.

   The declaration is spelled in longhands rather than the `background`
   shorthand only because the shorthand cannot carry a two-value position with a
   var() in it and keep `center` on the other axis. Computed background-size is
   still exactly `180px`, which is what grain-consistency.mjs compares.

   base.css holds this layer off the sheet's rounded top corners with
   `.content-sheet > .approach::after { top: var(--sheet-radius) }`. That guard
   is load-bearing: inside those notches .content-sheet has painted nothing, and
   a soft-light tile blended against transparency falls through to flat 40%
   grey, which is the grey wedge against the hero. Nothing above changes the
   element's identity, so the guard still matches; tools/sheet-join-probe.mjs
   is the regression check for it. */
.approach::after {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 4;
  pointer-events: none;
  /* var(), not the literal .4 this shipped with. The two happened to agree, so
     tools/grain-consistency.mjs passed and nobody noticed that this was the one
     grain in the site not actually reading the token -- which meant the
     2026-07-27 change of --grain-opacity to the lab's .30 would have moved
     every OTHER section and left Approach, the section it was requested for,
     alone. */
  opacity: var(--grain-opacity);
  background-image: url('../../assets/img/grain.png');
  /* The fallback is 0px and not 50%: with no JS the offset never arrives, and
     for a repeating noise tile any constant phase is the same picture. */
  background-position: center var(--approach-grain-y, 0px);
  background-size: var(--grain-size);
  background-repeat: repeat;
  mix-blend-mode: soft-light;
}

.approach-pin-wrap { position: relative; z-index: 1; }

/* The two beats that bracket the pin, one level ABOVE the wrapper rather than
   level with it. .content-sheet is the stacking context all three of these
   compete in, and at equal z-index the winner is DOM order -- the pin spacer
   comes after the opening beat and before the closing one, so a wrapper at the
   same level paints over the first and under the second.

   That did not matter until 2026-07-27, when the Cell Drift canvas was given a
   bleed past the wrapper's own box so the ambient could reach the chapter's
   edges (see .approach-cells). The bleed lands squarely on this copy, and a
   Voronoi web drawn OVER a paragraph reads as scratches on the type. Level 2
   puts both beats above the wrapper and therefore above the bleed.

   2 rather than 3 was originally chosen because .approach-field was also at 2
   and earlier in the DOM, so these beats sat above the wash as well. The field
   moved INSIDE the wrapper on 2026-07-27 (see the .approach-field block), so
   it is now at level 1 with everything else the pin holds and there is nothing
   left at 2 to compete with. The number is unchanged because the argument
   above it -- above the wrapper, therefore above the canvas bleed -- is the
   one that was always load-bearing. */
.approach-preface { position: relative; z-index: 2; }

/* ---------- The ambient field ----------
   The chapter's atmosphere: a cream-to-lilac field with violet leading, moving
   through pink into coral at the handoff into Ways, and one amber note high on
   the brain side. The physics of this chapter is GATHERING, so this layer is
   the still fluid and the particle strata below are the only thing that moves
   in it. It carries no animation of its own on purpose: a breathing field plus
   a drifting particle field is two ambient behaviours competing, and the brief
   for this section allows one.

   Four things about this element are load-bearing rather than stylistic.

   1. IT IS A CHILD OF .approach-pin-wrap, BETWEEN .approach-paper AND
      .approach-cells. It used to be a SIBLING of that wrapper, stacked above
      it at z-index 2, for a reason that was true as far as it went: the
      wrapper paints an opaque continuation of the sheet (.approach-paper)
      across its whole box, so a wash under z-index 1 is invisible for the
      entire pinned viewport, which is most of this chapter.

      What that argument missed is that a child cannot escape its parent's
      stacking context, so ABOVE THE WRAPPER means above every pixel the pin
      holds: the copy column, the mechanism list, the brain and the Cell Drift
      canvas. Reported three times by Daniel in three different words -- "there
      is a white softness going over everything including the text", "It's
      definitely fading the text and adjusting the paper", "i also don't see
      reduced opacity behind the text and brain". One cause, all three.
      Measured with tools/approach-wash-over-content.mjs before the move: the
      wash lifted GLYPH CORES -- fully opaque --ink-900 and --ink-600 type, not
      the paper around them -- by 15.5 levels at 2560x1440 and 19.0 at
      1440x900, against a 0.24 shader-motion noise floor. The same probe read
      .approach-cells at 0.28, i.e. at the floor, which is what a layer
      correctly UNDER the type looks like.

      The obvious alternative is a trap and it was checked before it was
      discarded: dropping the wrapper to z-index:auto so its children stop
      being trapped does nothing during the pin, because ScrollTrigger sets
      position:fixed on the wrapper and a fixed element always creates a
      stacking context whatever its z-index. It would have "worked" only in
      the 15% of the chapter that is not pinned.

      Inside the wrapper the slot is chosen by DOM order, not by renumbering:
      this element is z-index 0, the same level as .approach-paper, and later
      in the DOM, so it paints OVER the paper and UNDER .approach-cells (1) and
      .approach-scene (2). Nothing else in this file depends on the old
      numbers, so no other rule moved.

   2. IT CARRIES NO mix-blend-mode. base.css has to hold .approach::after off
      the sheet's rounded top corners, because inside those notches the sheet
      has painted nothing and a soft-light tile blended against transparency
      falls through to flat 40% grey -- the reported grey wedge. A plainly
      composited layer cannot do that. The `top` offset below is belt and
      braces so this element never paints outside the sheet's silhouette at
      all, whatever its alpha becomes later.

   3. EVERY STOP REACHES ZERO ALPHA AT THIS CHAPTER'S OWN TOP AND BOTTOM EDGE,
      via the mask envelope rather than by hand-tuning six radials. Each chapter
      join is a full-width lip of BARE veiled paper (--seam-lip, 16px at 390
      rising to 26px at 2552), and a wash that arrives at the join with any
      strength left puts that lip between two colours as a hard bright stripe.
      The ramp's floor is 280px and not the 220 first tried, because the widest
      the lip ever gets is 26px and the vh term cannot be relied on to make up
      the difference: at 1440x900 the lip is 23.04 and 22vh is only 198, which
      measured 9.57x, under the rule. 280 clears 26px at 10.8x on its own.
      Measured after: 12.2x at 1440, 11.9x at 2552, 17.5x at 390.

   4. ZERO STOPS ARE SPELLED rgba(r,g,b,0) (the --atmos-*-0 tokens), never
      `transparent`. `transparent` is rgba(0,0,0,0), and an engine that
      interpolates unpremultiplied darkens the whole fade toward black on its
      way out. Same reason base.css spells --sheet-contact-0 out.

   5. THE PIN FREEZES IT FOR FREE, AND THAT IS THE SECOND REASON THE SLOT
      MATTERS. See the block immediately below.

   Pigment placement is a contrast decision, not only a composition one. The
   measured luma cost of an --atmos-*-3 glow over the veiled field is about -12
   for violet, and this page's body copy and rail headings have almost no
   contrast headroom left. So violet sits at -2 and only over the BRAIN column,
   where it falls on graphics; the copy column gets --atmos-cream-2, which is a
   lifted warm white and therefore ADDS luminance and buys contrast back. */

/* ---------- Why the pin now freezes this for free ----------
   Reported for the fourth time, 2026-07-27: "the method section also still has
   the sliding strange texture on it. i am not sure if it's even grain. but it's
   something sliding when the paper background pins it needs to match." He was
   right not to call it grain. THIS element was the one sliding, for the whole
   864px of the pin at 2560x1440, and nothing measured it because every probe
   pointed at the grain.

   Why it slid: docs/ENGINEERING-NOTES.md, "a pin freezes only what is INSIDE
   the pinned wrapper". It was a sibling of .approach-pin-wrap, so ScrollTrigger
   never touched it: the composition froze and a six-stop wash kept travelling
   across it at full scroll rate. It reads as a sliding TEXTURE rather than as a
   sliding wash because of the arithmetic in "Flat scrims" in the engineering
   notes -- a layer of colour C at alpha a scales the base's local amplitude by
   (1-a), so a moving wash over the still paper fibre and the still grain
   modulates THEIR contrast, and a moving contrast envelope over a static
   texture is seen as the texture moving. Measured mean 2.204 / max 8.0 levels
   of change over half the pin, across the whole viewport.

   KEEP THAT LESSON. It is still true for anything else placed out there: the
   notes allow exactly two options, inside the pinned wrapper or driven by an
   offset that freezes while the wrapper is fixed, and there is no third.

   The first fix took option two, because at the time this layer had to span the
   preface and the closing beat and could not move inside. --approach-field-y
   translated it by GSAP's own displacement of the wrapper and
   --approach-field-shorten took the same distance back off the box so the
   bottom mask ramp still landed on the chapter's bottom edge. Both are GONE,
   deliberately, because this element now takes option one instead, and leaving
   hand compensation on an element the pin already freezes DOUBLE-COMPENSATES
   and puts the slide straight back.

   Option one arrives with the stacking fix above and costs nothing: a plain
   absolutely-positioned child of a position:fixed box is glued to the viewport
   by the browser's own box model, for free, on every frame including the
   anticipatePin frames where onUpdate never fires. That last part is a real
   improvement rather than a wash -- measured before the move,
   tools/approach-pin-motion.mjs Part D read a 183px single-frame jump of the
   painted field at the engage boundary, because anticipatePin moves the wrapper
   in a frame where the trigger's progress has not changed.

   The GEOMETRY IS THE SAME PICTURE, which is the reason none of the six radial
   stops below needed retuning. The old box was the chapter minus one
   --sheet-radius minus the pin distance, translated down by the displacement;
   the new box is the wrapper plus the same two published bleeds .approach-cells
   uses, i.e. the chapter minus one --sheet-radius minus the pin distance again,
   because the wrapper is exactly one pin distance shorter than its spacer.
   Measured at 2560x1440: 1883px tall painted at viewport row -213 before,
   1853px tall at viewport row -213 after.

   Full bleed for free, too: the same --approach-cells-bleed-* lengths mean this
   wash reaches the opening and the closing beat exactly as the canvas does, and
   the top bleed stops one --sheet-radius short of the section's top edge, which
   is the corner-notch guard the old `top: var(--sheet-radius)` was there for.
   Do not "tidy" the negative offset back to inset: 0.

   Both removed properties fell back to 0px, so no-JS and the static/mobile
   presentations are unaffected by their removal; the bleeds fall back to 0px in
   the same way, which is a box exactly filling the wrapper. */
.approach-field {
  --approach-field-ramp: clamp(280px, 22vh, 340px);
  position: absolute;
  z-index: 0;
  /* Bled past the wrapper's own box, exactly as .approach-cells is and from the
     same two published lengths, so the two ambient layers share one silhouette
     and one pair of chapter edges. The top bleed already has --sheet-radius
     subtracted out of it in syncCellsBleed(), so this layer still cannot paint
     inside .content-sheet's two rounded corner notches and over the hero --
     where a layer over unpainted area reads as a flat grey wedge. */
  top: calc(-1 * var(--approach-cells-bleed-top, 0px));
  right: 0;
  left: 0;
  /* Stated rather than left to top + bottom purely to match .approach-cells
     line for line; a <span> is not a replaced element so either spelling works
     here, but two layers that must stay the same size should be the same
     arithmetic. */
  height: calc(100% + var(--approach-cells-bleed-top, 0px) + var(--approach-cells-bleed-bottom, 0px));
  pointer-events: none;
  background-image:
    /* Amber, high on the brain side: the chapter's one upward note, and the
       colour the filled brain resolves into. */
    radial-gradient(54% 26% at 76% 21%, var(--atmos-amber-2), var(--atmos-amber-0) 74%),
    /* Coral across the foot: the handoff into Ways, which picks these
       particles up as one continuous liquid ribbon. -2 rather than -1 because
       this band falls on the closing beat, whose only copy is --ink-900 serif
       at 13:1, i.e. the largest contrast budget in the chapter. */
    radial-gradient(96% 30% at 48% 97%, var(--atmos-coral-2), var(--atmos-coral-0) 76%),
    /* Pink, the gathering shoulder between violet and coral. Pushed down to 84%
       so it clears the band the brain caption travels through: the field is
       DOCUMENT-positioned while the pinned scene is viewport-fixed, so the
       composition sweeps about 30% of this element's height during the pin and
       the caption passes through roughly 49% to 79% of it. */
    radial-gradient(78% 34% at 68% 84%, var(--atmos-pink-2), var(--atmos-pink-0) 74%),
    /* The lead pigment, deliberately parked over the brain column at 79% so
       the strongest violet in the chapter lands on graphics and not on the
       mechanism list. It stays at -2 and not -3: measured, this stop alone
       already costs .approach-brain-caption 0.32 of its contrast ratio, and
       that caption is the tightest target in the chapter at about 5.0:1. */
    radial-gradient(64% 37% at 79% 46%, var(--atmos-violet-2), var(--atmos-violet-0) 72%),
    /* A broad violet breath so the section reads as a room with light in it
       rather than as a spot on paper. -1 is the only strength safe full-bleed. */
    radial-gradient(122% 66% at 44% 50%, var(--atmos-violet-1), var(--atmos-violet-0) 78%),
    /* Cream over the copy column. This is the only stop above -1 that touches
       text, and it is the one that adds light rather than subtracting it. */
    radial-gradient(92% 52% at 19% 25%, var(--atmos-cream-2), var(--atmos-cream-0) 74%);
  background-repeat: no-repeat;

  /* ---------- Three mask layers, intersected ----------
     Layer 1 quiets the wash behind the copy column, layer 2 behind the brain,
     layer 3 is the chapter-edge seam ramp that has always been here. They
     MULTIPLY, because mask-composite's `intersect` is Porter-Duff source-in and
     that is a product of alphas. The default is `add`, i.e. union, and under
     `add` layers 1 and 2 -- opaque black everywhere outside their own guard --
     would swallow the seam ramp whole and put the wash on the Ways lip at full
     strength. So the composite declaration is not a nicety, it is the rule that
     keeps constraint 1 satisfied, and it is verified rather than assumed:
     sweeping --approach-guard-copy-floor from 1 to 0 in a real headed Chrome
     moves the masked centre from 0 to 249 of 255 while the ramp's own top and
     bottom rows do not move at all. tools/approach-wash-over-content.mjs
     re-measures the same sweep on the live page.

     WHY THE GUARDS EXIST. Daniel, twice: "i also don't see reduced opacity
     behind the text and brain if it's there make a more gradual expanded
     obvious fallback there". The reduction he could not see was real -- the
     shader's own textSafe and capMask -- but this wash was stacked ABOVE the
     canvas and above the copy and painted it straight back out. Moving the wash
     under both (see the block above) is what makes any reduction visible at
     all; these two layers are the wash's own share of it, because after the
     move the wash is still the loudest ambient in the chapter.

     GRADUAL AND EXPANDED IS THE POINT, not a hole. Each guard is a single
     interpolation from its floor at the centre to full strength at 100% of its
     own radius, with no plateau and no stop in between, and mechanisms.js sizes
     that radius at more than twice the element it protects (syncAmbientGuards).
     At 2560x1440 the copy guard is about 1250px across a 550px column. There is
     no edge anywhere to see; the ambient simply thins where the reading is.

     The geometry is published from the LIVE RECTS of .approach-copy-column and
     .approach-brain-stage, never hard-coded, because the copy column is nudged
     right by a clamp() and the brain stage is min(30vw, 440px) -- both move with
     the viewport, and a guard that misses what it is guarding is worse than no
     guard. The fallbacks below are the measured 2560x1440 values, so a no-JS
     render still gets a sane reduction rather than none. */
  -webkit-mask-image:
    radial-gradient(var(--approach-guard-copy, 48% 44% at 29% 50%), rgba(0, 0, 0, var(--approach-guard-copy-floor, .32)), #000 100%),
    radial-gradient(var(--approach-guard-brain, 34% 40% at 72% 50%), rgba(0, 0, 0, var(--approach-guard-brain-floor, .38)), #000 100%),
    linear-gradient(180deg,
      rgba(0, 0, 0, 0) 0,
      #000 var(--approach-field-ramp),
      #000 calc(100% - var(--approach-field-ramp)),
      rgba(0, 0, 0, 0) 100%);
  -webkit-mask-composite: source-in;
  mask-image:
    radial-gradient(var(--approach-guard-copy, 48% 44% at 29% 50%), rgba(0, 0, 0, var(--approach-guard-copy-floor, .32)), #000 100%),
    radial-gradient(var(--approach-guard-brain, 34% 40% at 72% 50%), rgba(0, 0, 0, var(--approach-guard-brain-floor, .38)), #000 100%),
    linear-gradient(180deg,
      rgba(0, 0, 0, 0) 0,
      #000 var(--approach-field-ramp),
      #000 calc(100% - var(--approach-field-ramp)),
      rgba(0, 0, 0, 0) 100%);
  mask-composite: intersect;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
}

/* The stacked/mobile presentation has one column, not two, so a guard aimed at
   a left-hand copy column and a right-hand brain is aiming at nothing. The
   rects mechanisms.js measures are still correct there -- the two guards simply
   sit above each other and cover most of the screen -- so nothing is disabled;
   this note exists so the next reader does not "fix" an overlap that is right. */

/* ---------- Approved introductory beat ---------- */

/* Sized by its content. The previous clamp(270px, 34vh, 360px) box with
   justify-content:flex-end parked the whole beat at the bottom and left the
   rest as empty paper above the kicker, which read as a hole in the page
   rather than as breathing room.

   The opening is now a single kicker-and-sentence pair: the SHOW MY WORKING
   kicker and its claim have moved to .approach-close so the beat opens the
   section instead of delaying it, and the padding came down with them. The
   floor on the top padding is deliberate: the wrapper below paints its own
   copy of the sheet, and .content-sheet's top highlight gradient has faded to
   nothing by 218px down the sheet. Keeping this beat taller than that keeps
   the copy on plain stock, with nothing to reproduce. */
.approach-preface {
  display: flex;
  flex-direction: column;
  align-items: center;
  /* The shared "introduction to primary visual" rhythm is 56 to 72px on
     desktop, and the gap it refers to is this padding PLUS the slack
     .approach-pin-wrap's 100svh centring leaves above the scene. Measured at
     1440x900: 28 + 35 = 63, inside the target, so this value is deliberately
     left where it was. Raising it to 47 was tried and measured 82, i.e. over.
     Above roughly 1100px of viewport height the centring dominates entirely
     and nothing set here changes the gap; see the note in the report. */
  padding-block: clamp(56px, 5.4vh, 74px) clamp(28px, 3vh, 40px);
  text-align: center;
}
.approach-preface-kicker {
  margin: 0;
  color: var(--rose-muted);
  font-family: var(--font-mono);
  font-size: .75rem;
  font-weight: 400;
  letter-spacing: .26em;
  line-height: 1.4;
}
.approach-preface-copy {
  max-width: 37rem;
  margin-top: 20px;
  color: #463c4e;
  font-family: var(--font-serif);
  font-size: clamp(1.32rem, 2vw, 1.5rem);
  line-height: 1.42;
  text-wrap: pretty;
}
.approach-preface-copy em { font-family: inherit; }

/* ---------- Pinned scene ---------- */

/* The pinned stage. It carries its own opaque paper (below), so while the pin
   holds, the fibre holds with it instead of streaming past a frozen
   composition.

   min-height: 100svh is load-bearing and is NOT decorative slack. Anything
   below the pin spacer is live page, and the spacer's bottom edge sits at
   (wrapHeight + pinDistance - scrolled) above the fold, so it rises into view
   partway through the pin unless the wrapper is at least one viewport tall.
   The moment it does, the still fibre inside the pin meets the moving fibre of
   the sheet along a visible line -- exactly the tonal step the earlier opaque
   copy was reverted for. At 100svh that edge stays off-screen for the whole
   pin, and the following beat arrives exactly as the pin releases. The height
   that WAS free slack, .approach-scene's own 100svh, is gone: the composition
   is sized by its content and centred in the stage. */
.approach-pin-wrap {
  min-height: 100svh;
  display: grid;
  align-items: center;
}

/* An opaque continuation of .content-sheet, not a second sheet.
   Same stock, same scale, same horizontal centring, and -- once mechanisms.js
   has written --approach-paper-y -- the same phase, so every edge of this box
   is a pixel-exact continuation of the page's own paper and cannot read as a
   tonal step. The technique is js/seams.js's: the offset is the element's own
   PAINTED distance down the sheet (each rect rounded independently, never the
   difference), fed in raw and unreduced because background-repeat: repeat-y
   makes background-position periodic. See docs/ENGINEERING-NOTES.md.

   Progressive enhancement: with no JS this sits at phase 0, which is a 1-3
   level step at its edges rather than a broken layout.

   The URL is spelled out rather than reusing base.css's --paper-sheet token:
   measured in Chrome, a relative url() carried in a custom property resolves
   against the stylesheet that USES it, not the one that declares it, so
   var(--paper-sheet) here requests css/assets/textures/paper-sheet.webp and
   404s. --paper-sheet-size is a plain length and is safe to share. */
.approach-paper {
  position: absolute;
  z-index: 0;
  inset: 0;
  pointer-events: none;
  background-color: var(--cream-100);
  /* The veil is the FIRST layer, exactly as .content-sheet and .section-seam::before
     paint it, so all three composite to the identical colour. It has to be a flat
     layer and not a gradient: these three elements are 6300px, 26px and one viewport
     tall respectively, and a gradient sized 100% 100% resolves to different pixels in
     each, while the phase match below is only one pixel wide. --paper-veil-layer is
     shared from base.css for that reason. Sized 100% 100% no-repeat so it stays flat
     whatever this box's height is. */
  background-image: var(--paper-veil-layer), url('../../assets/textures/paper-sheet.webp');
  background-position: top center, center var(--approach-paper-y, 0px);
  background-size: 100% 100%, var(--paper-sheet-size) auto;
  background-repeat: no-repeat, repeat-y;
}

/* Mobile paper swap, 2026-07-30. Agent P's base.css media query (same 700px
   breakpoint) already lowers --paper-sheet-size to max(100%, 768px) below
   this width, and this element already inherits that -- it is a plain length
   and safe to share. What it CANNOT inherit is --paper-sheet itself: this
   file's own comment above explains why (a relative url() in a custom
   property resolves against the stylesheet that DECLARES it, not the one
   that uses it, so var(--paper-sheet) from here 404s). Left alone, this
   element keeps decoding the full 2560x3412 / 33.3 MB original on every
   phone even after P's fix -- correctly scaled and phase-matched, just never
   actually smaller on the wire or in memory, which is the entire point of
   the exercise on Jess's phone. Restating the URL here, at this file's own
   path depth, is the fix; the veil layer is restated unchanged alongside it
   only because background-image is one property covering both layers -- CSS
   cannot override a single layer of a multi-layer background. Breakpoint
   matches base.css's exactly: a mismatch here would tile a different photo
   from the sheet behind it at some widths, which is exactly what seams.js
   and the phase-lock above exist to prevent.

   Breakpoint widened 2026-08-18 (round 3) to also OR in
   `(pointer: coarse) and (hover: none)`, same change and same reasoning as
   base.css's own copy of this comment: max-width:700px alone missed an
   unfolded foldable (900 CSS px, coarse pointer, no hover - confirmed with
   tools/p-foldable-breakpoint-check.mjs), which kept loading the full
   2560x3412 stock here even after base.css's copy was fixed. Still matches
   base.css's query exactly, which is the part that actually matters for the
   phase-lock. */
@media (max-width: 700px), (pointer: coarse) and (hover: none) {
  .approach-paper {
    background-image: var(--paper-veil-layer), url('../../assets/textures/paper-sheet-mobile.webp');
  }
}

/* ---------- Ambient field: Cell Drift (WebGL Voronoi) ----------
   Replaces the Perlin flow-field canvas wholesale -- Daniel's own words:
   "Cell drift replaces the existing Waves Perlin". This is a transplant of
   tmp/anim-lab-v2/05-cell-drift/index.html, the candidate Daniel picked and
   tuned, not a redesign: same shader technique (two Voronoi bisector fields,
   a primary structural web plus a 47%-scale quiet second layer, no cell ever
   filled), same brand palette family, same text-safe attenuation. Two changes
   on top, both requested directly and both live in the fragment shader in
   mechanisms.js: the composition is weighted toward the top-left rather than
   centred, and the palette's warm end was pulled from amber toward violet
   ("less yellows perhaps move to purple here").

   Everything about POSITION is carried over unchanged from the old canvas:
   same slot, sibling of .approach-paper inside .approach-pin-wrap, so it pins
   and holds still with the paper while the brain fills. Moved outside the
   wrapper it would slide during the pin -- the bug round 2 fixed. Unlike the
   old canvas's grain-style phase lock, this element needs NO per-frame scroll
   sync of its own: it is a plain absolutely-positioned child of the wrapper
   GSAP pins, so the browser freezes its screen position for free the instant
   the wrapper becomes position:fixed. mechanisms.js draws entirely in the
   canvas's own local resolution/time/pointer space and never reads scrollY,
   so there is no arithmetic here that could go stale a frame behind Lenis --
   see tools/approach-cell-drift.mjs for the per-frame measurement that
   confirms this holds across a continuous scroll through the whole pin.

   The background-image below is NOT a backdrop for the canvas bitmap -- a
   <canvas> with nothing drawn on it is fully transparent, and CSS background
   paints BEHIND that bitmap. It is the NO-JS / pre-first-frame / WebGL-failure
   resting state: a quiet crosshatch in the same palette the shader uses, so a
   script failure or a slow first paint never leaves a hole where the field
   used to be. It carries no mix-blend-mode on purpose -- see "Three CSS
   traps", trap 1, in docs/ENGINEERING-NOTES.md -- so it composites plainly
   over the opaque .approach-paper behind it regardless of stacking context.

   THE SECOND LATTICE. Reported 2026-07-27: "you put a secondary lattice
   structure remove it just have the one codex built". This was it, and the
   original transplant lost the one line of the approved demo that prevented
   it. In tmp/anim-lab-v2/05-cell-drift/index.html the crosshatch is a
   SEPARATE element, `.fallback`, and the demo carries
   `.study.is-webgl .fallback { opacity: .09 }` -- once the shader is actually
   drawing, the crosshatch is all but gone. Folding the crosshatch into the
   canvas's own background-image to avoid adding an element (the shared brief
   forbids new files, not new elements, but one element fewer was the right
   call) dropped that rule, so it ran at full strength UNDER a transparent
   canvas for ever, and the two repeating linear-gradients read as exactly what
   Daniel called them: long straight segments crossing the Voronoi polygons at
   two fixed angles, on a 300x232 and a 340x258 grid.

   .approach--cells-live is written by mechanisms.js only after WebGL has
   linked AND drawn its first frame, in the same synchronous block, so there is
   no window where both are visible and no handoff to see. The whole image goes,
   not only the two hatches: the `at 24% 22%` violet radial sat directly on
   .approach-kicker, and measured with
   `OFF=.approach-cells node tools/approach-contrast.mjs` it was most of that
   target's contrast cost. Removing it is what bought the headroom Job 2's
   "more prominent" then spent.

   FULL BLEED, and why the bleed is spelled as two published lengths rather
   than as a bigger box. Reported 2026-07-27: "The method cuts off the
   background ambient animation from the top and bottom go full bleed with the
   ambient ... i forgot to flag the strange crop". He is describing the
   canvas's own box. This element is inset:0 on .approach-pin-wrap, and that
   wrapper is one viewport tall and starts BELOW the chapter's opening beat and
   ends ABOVE its closing one, so the field stopped dead at two horizontal
   lines in the middle of bare paper. edgeFade only softened them; it could not
   move them.

   It cannot simply be lifted out of the wrapper. Being a plain child of the
   pinned wrapper is the entire reason it holds still during the pin for free
   (see the note above, and Job 1 of this same pass, which spent a day on
   exactly this failure mode for a layer that was NOT inside it). So it stays
   where it is and grows past the wrapper's edges instead.

   The two bleeds are measured, never assumed, and they are measured against
   the PIN SPACER rather than the wrapper: the spacer stays in flow, and its
   top and bottom edges are exactly where the wrapper sits before the pin and
   after it. Pre-pin the wrapper is at the spacer's top, so the top bleed
   reaches the chapter's opening beat; post-pin GSAP has translated it to the
   spacer's bottom, so the bottom bleed reaches the closing beat. In between,
   both bleeds are off-screen, because the wrapper is 100svh and covers the
   viewport for every frame of the pin. Same argument the paper phase lock and
   the field freeze both rely on, and it is why a bleed of a couple of hundred
   pixels covers a chapter that is 2763px tall.

   The top bleed stops one --sheet-radius short of the section's own top edge,
   exactly as .approach-field does (which since 2026-07-27 is not a parallel
   arrangement but the identical one -- the wash is a sibling of this canvas
   inside the wrapper and takes the same two bleeds), so this layer cannot paint
   inside
   .content-sheet's two corner notches and over the hero. The shader's own
   vertical taper (edgeFade in mechanisms.js) now lands on the chapter's edges
   instead of on the wrapper's, which is what turns a crop line into a fade.

   height is stated rather than left to top + bottom: for an absolutely
   positioned REPLACED element -- and a <canvas> is one -- `height: auto`
   resolves to the intrinsic height and the `bottom` offset is discarded as
   over-constrained, which silently leaves the box its old size. */
.approach-cells {
  position: absolute;
  z-index: 1;
  top: calc(-1 * var(--approach-cells-bleed-top, 0px));
  right: 0;
  left: 0;
  display: block;
  width: 100%;
  height: calc(100% + var(--approach-cells-bleed-top, 0px) + var(--approach-cells-bleed-bottom, 0px));
  overflow: hidden;
  pointer-events: none;
  /* Weighted top-left to match the shader's own compBias term: strongest
     violet-plum blob near the top-left corner, a much fainter coral note
     lower and more central so the resting frame still reads as reaching
     across the box rather than stopping at one corner. No mask: a repeating
     hatch has no silhouette of its own to read as "a rectangle", and both
     radial stops reach rgba(...,0) well inside the box's own edges. */
  background-image:
    linear-gradient(124deg, rgba(123, 111, 208, 0) 48.4%, rgba(123, 111, 208, .20) 49.05%, rgba(123, 111, 208, 0) 49.7%),
    linear-gradient(38deg, rgba(236, 95, 156, 0) 48.9%, rgba(236, 95, 156, .15) 49.3%, rgba(236, 95, 156, 0) 49.75%),
    radial-gradient(58% 50% at 24% 22%, rgba(180, 117, 197, .17), rgba(180, 117, 197, 0) 76%),
    radial-gradient(52% 44% at 46% 62%, rgba(244, 136, 92, .09), rgba(244, 136, 92, 0) 78%);
  background-repeat: repeat, repeat, no-repeat, no-repeat;
  background-size: 300px 232px, 340px 258px, auto, auto;
}
/* The shader is live and is the only cell system on the page. See the block
   above; this is the live-site equivalent of the demo's
   `.study.is-webgl .fallback { opacity: .09 }`. */
.approach--cells-live .approach-cells { background-image: none; }

.approach-scene {
  position: relative;
  z-index: 2;
  display: grid;
  align-items: center;
  /* Content-driven. The padding is what keeps the brain caption, which hangs
     46px below its figure, clear of this box's own overflow clip. */
  padding-block: clamp(20px, 2.6vh, 40px);
  overflow: hidden;
}
/* The composition band. It is NOT the shared .container width: it matches the
   hero's --hero-stage band, calc(var(--container) + 76px), so this heading
   begins on the same left rail as the hero's h1 rather than 42px outside it.
   Measured before the change at 1440x900: hero h1 left 116px, this heading
   left 74px; at 2552x1400, 480 against 630. Both derive from --container, so a
   token change still moves them together.

   The 1700px twin below is the hero's own 1440p rule, at the hero's own
   breakpoint. If the hero's band ever changes, this has to follow it; there is
   no shared token for it yet, and hero.css is not this file's to edit. */
.approach-scene-grid {
  display: grid;
  grid-template-columns: minmax(0, 1.02fr) minmax(320px, .98fr);
  gap: clamp(30px, 4vw, 72px);
  align-items: center;
  width: 100%;
  max-width: calc(var(--container) + 76px);
}
@media (min-width: 1920px) and (min-height: 1080px) {
  .approach-scene-grid { max-width: 1700px; }
}
/* 2026-07-27, Daniel: "bring text over to the right a little". The column
   used to sit flush on the grid track's own left edge, hard against the
   shared .container rail the hero and Ways both use. This is a nudge on the
   COLUMN's own padding, not a wider stage: .approach-scene-grid's max-width
   and the container rail are both untouched, so the rail is still shared.
   Reset to 0 in the sub-768px stack below, where the column is full-width and
   there is no "right" to nudge toward. */
.approach-copy-column {
  position: relative;
  z-index: 2;
  padding-left: clamp(18px, 1.8vw, 40px);
}

/* The heading sits directly on the shared paper stock. Its emphasis comes
   from type and the moving glass highlight below, not a second paper card. */
.approach-header-paper {
  margin: 0;
  padding: 0;
}
.approach-kicker {
  display: flex;
  align-items: center;
  gap: 10px;
  margin: 0;
  color: var(--rose-muted);
  font-family: var(--font-mono);
  font-size: .75rem;
  letter-spacing: .22em;
  line-height: 1.4;
}
.approach-kicker > span {
  width: 9px;
  height: 9px;
  flex: none;
  border-radius: 50%;
  background: linear-gradient(135deg, #7b6fd0, #ec5f9c 55%, #f7c063);
}
.approach-header-paper h2 {
  margin-top: 18px;
  color: var(--ink-900);
  font-size: clamp(2.375rem, 4.4vw, 3.625rem);
  line-height: 1.08;
  letter-spacing: -.035em;
  overflow: visible;
}
.approach-header-paper h2 > span,
.approach-header-paper h2 em {
  display: block;
  white-space: nowrap;
}
.approach-header-paper h2 em {
  line-height: 1.12;
  padding-block: 0 .12em;
  margin-bottom: -.05em;
  background: var(--grad-text);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
.approach-header-paper > p:last-child {
  max-width: 29rem;
  /* 24, not 20: the heading's em carries padding-block 0 .12em and a -.05em
     bottom margin, so a 20px margin measured 17px of actual gap, under the
     shared 20 to 28px heading-to-supporting-copy target. 24 measures 21. */
  margin: 24px 0 0;
  color: var(--ink-600);
  font-size: 1.094rem;
  line-height: 1.55;
}

/* ---------- Mechanisms + travelling glass highlight ---------- */

/* 2026-07-27, Daniel: "the highlight glass probably is too wide". Measured
   before this change: the panel (.approach-active-highlight, which insets
   -10px/+10px off THIS box) ran to 795px at 2560x1440 because this element
   had no max-width and simply filled the copy column. The actual copy never
   used that width: the widest thing any of the three mechanisms ever renders
   is the paragraph's own 28.25rem (452px) cap, measured at 444-449px of real
   glyphs across all three entries (tools/_scratch-measure-text.mjs), and the
   two headings (h3) top out at 228px. 33rem gives the 452px paragraph cap
   its 26px+22px padding back (500px) plus a small cross-browser buffer, so
   the panel now hugs the text instead of running on past it, at all three
   mechanism states. */
.approach-mechanisms {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 4px;
  margin-top: 32px;
  max-width: 33rem;
}
.approach-active-highlight {
  position: absolute;
  z-index: 0;
  left: -10px;
  right: -10px;
  top: 0;
  height: 0;
  border: 1px solid rgba(255, 255, 255, .74);
  border-radius: 18px;
  opacity: 0;
  background:
    radial-gradient(80% 120% at 0% 50%, rgba(var(--approach-accent-rgb), .18), transparent 72%),
    linear-gradient(110deg, rgba(255, 253, 248, .50), rgba(255, 255, 255, .22));
  box-shadow:
    0 18px 34px -24px rgba(var(--approach-accent-rgb), .48),
    inset 0 1px 0 rgba(255, 255, 255, .72),
    inset 0 -1px 0 rgba(255, 255, 255, .25);
  -webkit-backdrop-filter: blur(18px) saturate(1.18);
  backdrop-filter: blur(18px) saturate(1.18);
  transition:
    top .62s var(--ease-brand),
    height .62s var(--ease-brand),
    opacity .32s ease,
    background .45s var(--ease-brand),
    box-shadow .45s var(--ease-brand);
  pointer-events: none;
}
.approach-mechanism {
  position: relative;
  z-index: 1;
  padding: 20px 22px 20px 26px;
  border-radius: 16px;
  opacity: .38;
  transition: opacity .5s ease, transform .55s var(--ease-brand);
}
.approach-mechanism.is-active {
  opacity: 1;
  transform: translateX(4px);
}
.approach-mechanism-bar {
  position: absolute;
  top: 20px;
  bottom: 20px;
  left: 0;
  width: 3px;
  border-radius: 3px;
  background: var(--violet-deep);
  transform: scaleY(0);
  transform-origin: top;
  transition: transform .55s var(--ease-brand);
}
.approach-mechanism.is-active .approach-mechanism-bar { transform: scaleY(1); }
.approach-mechanism[data-accent="pink"] .approach-mechanism-bar { background: var(--pink); }
.approach-mechanism[data-accent="coral"] .approach-mechanism-bar { background: #f4885c; }
.approach-mechanism-meta {
  display: flex;
  align-items: baseline;
  gap: 12px;
  margin: 0;
  font-family: var(--font-mono);
  font-size: .688rem;
  letter-spacing: .2em;
  line-height: 1.35;
  color: var(--ink-700);
}
.approach-mechanism-meta b { color: var(--violet-ink); letter-spacing: .14em; }
.approach-mechanism[data-accent="pink"] .approach-mechanism-meta b { color: var(--pink-link); }
.approach-mechanism[data-accent="coral"] .approach-mechanism-meta b { color: #ad452c; }
.approach-mechanism h3 {
  margin-top: 9px;
  color: var(--ink-900);
  font-size: 1.5625rem;
  line-height: 1.15;
}
.approach-mechanism > p:last-child {
  max-width: 28.25rem;
  margin-top: 8px;
  color: var(--ink-600);
  font-size: .969rem;
  line-height: 1.55;
}
.approach-mechanism > p:last-child em { color: var(--ink-900); }

/* ---------- Brain: fill and engraving are deliberately separate layers.
   The PNG alpha silhouette masks the full-spectrum ink layer; the clip layer
   rises independently, so every fill percentage has visible colour. */

.approach-brain-column {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: min(78vh, 660px);
}
.approach-brain-stage {
  position: relative;
  width: min(30vw, 440px);
  aspect-ratio: 1047 / 905;
  margin: 0;
}
/* The responsive aura. It is driven entirely by --approach-accent-rgb, which
   mechanisms.js already publishes per active mechanism, so there is ONE state
   channel for the section's colour rather than a stylesheet path and a parallel
   inline path that can disagree. (It used to be written from JS as an inline
   `background`, which also spelled its far stop `transparent` -- rgba(0,0,0,0),
   which an engine interpolating unpremultiplied drags toward black on the way
   out. Both are fixed here.)

   Retuned to the shared ambient temperament in the 2026-07-26 cohesion pass:
   9s ease-in-out was a pulse rather than a breath, and its 1.08 scale and .13
   opacity swing both exceeded the published ceilings. It now runs at
   --dur-ambient on the symmetric --ease-ambient, at exactly --atmos-scale and
   inside --atmos-fade. */
.approach-brain-aura {
  position: absolute;
  z-index: 0;
  top: 52%;
  left: 50%;
  width: 118%;
  height: 118%;
  border-radius: 50%;
  background: radial-gradient(circle,
    rgba(var(--approach-accent-rgb), .9),
    rgba(var(--approach-accent-rgb), 0) 66%);
  filter: blur(46px);
  opacity: .32;
  transform: translate(-50%, -50%);
  transition: background .55s var(--ease-brand);
  animation: approach-aura-breathe var(--dur-ambient) var(--ease-ambient) infinite;
}

/* ---------- Concentration rings ----------
   The chapter's physics is gathering, so these CONTRACT. A ring fades in wide
   and faint, draws inward and gives up before it reaches the engraving, which
   reads as the field being pulled toward the brain. An expanding ring would
   read as diffusion, which is the hero's behaviour and the one thing this
   section must not copy.

   Deliberately at the edge of perception: a 1px border at .30 of the accent,
   peaking at .46 opacity, so the strongest pixel this ever paints is about .14
   of the accent over cream. Two rings on one --dur-ambient-slow cycle, the
   second half a period out of phase, so one passes roughly every 14.5s and
   never two at once. */
.approach-rings {
  position: absolute;
  z-index: 0;
  inset: 0;
  pointer-events: none;
  /* The blur is what makes these read as ripples in a fluid rather than as
     drawn circles. Measured on the first pass, a 1px hard-edged ring at this
     diameter reads as a HUD annulus, which is exactly the clinical/technical
     register this section is not allowed to have. Blurring the ring and
     thickening it to 2px so it survives the blur keeps the same energy with
     none of the instrumentation. On the parent, not on the rings themselves,
     so the transform animation stays a pure compositor property. */
  filter: blur(2.5px);
}
.approach-rings::before,
.approach-rings::after {
  content: '';
  position: absolute;
  top: 52%;
  left: 50%;
  width: 104%;
  aspect-ratio: 1;
  border: 2px solid rgba(var(--approach-accent-rgb), .26);
  border-radius: 50%;
  opacity: 0;
  transform: translate(-50%, -50%) scale(1.3);
  animation: approach-ring-gather var(--dur-ambient-slow) var(--ease-ambient) infinite;
}
.approach-rings::after { animation-delay: calc(var(--dur-ambient-slow) / -2); }

/* ---------- Colour that arrives with the ink ----------
   Both of these read --approach-fill / --approach-fill-n rather than a clock,
   so they are scroll-driven, not a second ambient loop. At fill 0 they are at
   opacity 0 and there is nothing to see; the composition starts as bare
   engraving exactly as it did before.

   The bloom rides the ink SURFACE: top is 100% minus the fill, which is the
   same arithmetic .approach-brain-fill-reveal's clip-path uses, so the glow and
   the ink edge cannot drift apart. */
.approach-brain-bloom {
  position: absolute;
  z-index: 0;
  top: calc(100% - var(--approach-fill, 0%));
  left: 50%;
  width: 104%;
  height: 38%;
  border-radius: 50%;
  background: radial-gradient(58% 50% at 50% 50%,
    rgba(var(--approach-accent-rgb), .5),
    rgba(var(--approach-accent-rgb), 0) 76%);
  filter: blur(30px);
  opacity: calc(var(--approach-fill-n, 0) * .72);
  transform: translate(-50%, -50%);
  pointer-events: none;
}

/* Amber near the upper portion of the filled brain: upward integration, and
   the only place amber appears at strength in this chapter. The max() gate
   holds it at zero until the ink is past the halfway mark, so it reads as
   something the fill ARRIVES at rather than a tint that was always there.
   calc() cannot mix a <number> with a <percentage> in one sum, which is why
   this reads --approach-fill-n and not --approach-fill. */
.approach-brain-lift {
  position: absolute;
  z-index: 0;
  top: -6%;
  left: 50%;
  width: 96%;
  height: 60%;
  border-radius: 50%;
  background: radial-gradient(60% 62% at 50% 62%,
    rgba(247, 178, 95, .46),
    rgba(247, 178, 95, .16) 52%,
    rgba(247, 178, 95, 0) 78%);
  filter: blur(26px);
  opacity: calc(max(0, var(--approach-fill-n, 0) - .45) * 1.45);
  transform: translateX(-50%);
  pointer-events: none;
}
.approach-brain-fill-reveal {
  position: absolute;
  z-index: 1;
  inset: 0;
  overflow: hidden;
  clip-path: inset(calc(100% - var(--approach-fill)) 0 0 0);
  -webkit-clip-path: inset(calc(100% - var(--approach-fill)) 0 0 0);
  will-change: clip-path;
}
.approach-brain-fill {
  position: absolute;
  inset: 0;
  display: block;
  background: linear-gradient(to top, #6f5fd0 0%, #a94fa0 30%, #ec5f9c 52%, #f8845d 78%, #f7c063 100%);
  -webkit-mask: url('../../assets/approach/brain.webp') center / contain no-repeat;
  mask: url('../../assets/approach/brain.webp') center / contain no-repeat;
}
.approach-brain-line {
  position: absolute;
  z-index: 2;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  mix-blend-mode: multiply;
  opacity: .82;
  filter: contrast(1.07) drop-shadow(0 9px 4px rgba(76, 58, 86, .18));
  pointer-events: none;
}
.approach-brain-hud {
  position: absolute;
  z-index: 3;
  top: -13%;
  right: -4px;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 12px;
  color: var(--ink-700);
  font-family: var(--font-mono);
  font-size: .813rem;
  letter-spacing: .08em;
  line-height: 1;
}
.approach-brain-tag {
  color: var(--violet-ink);
  font-size: .813rem;
  letter-spacing: .16em;
  transition: color .5s ease;
}
.approach-brain-ticks {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 6px;
  margin-top: 2px;
}
.approach-brain-ticks i {
  display: block;
  width: 12px;
  height: 3px;
  border-radius: 3px;
  background: rgba(42, 34, 48, .16);
  transition: width .45s var(--ease-brand), background .45s var(--ease-brand);
}
.approach-brain-ticks i:first-child { width: 22px; background: var(--violet-deep); }
.approach-brain-caption {
  position: absolute;
  z-index: 3;
  bottom: -46px;
  left: 50%;
  width: max-content;
  max-width: 100%;
  margin: 0;
  color: var(--mood-line);
  font-family: var(--font-serif);
  font-size: 1.063rem;
  font-style: italic;
  line-height: 1.25;
  text-align: center;
  transform: translateX(-50%);
  transition: opacity .24s ease;
}

/* Reduced-motion and coarse-pointer tablets keep the editorial two-column
   scene, but remove the full-height pinned stage. */
.approach--static .approach-pin-wrap { min-height: 0; }
.approach--static .approach-scene {
  overflow: visible;
  padding-block: clamp(66px, 8vw, 96px);
}

@keyframes approach-aura-breathe {
  0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: .30; }
  50% { transform: translate(-50%, -50%) scale(var(--atmos-scale)); opacity: .40; }
}
/* Contracting, not expanding: this chapter gathers. */
@keyframes approach-ring-gather {
  0% { transform: translate(-50%, -50%) scale(1.3); opacity: 0; }
  18% { opacity: .46; }
  64% { opacity: .30; }
  100% { transform: translate(-50%, -50%) scale(.84); opacity: 0; }
}
/* approach-cue-bob was removed 2026-07-29: defined here but referenced by
   nothing (HANDOFF "Still open" item 23). The two keyframes above are live --
   approach-aura-breathe on .approach-brain-aura, approach-ring-gather on
   .approach-rings::before/::after. */

/* ---------- Natural-flow fallback: mobile and reduced motion ---------- */

@media (max-width: 768px) {
  .approach { --approach-fill: 100%; --approach-fill-n: 1; }
  .approach-preface {
    min-height: auto;
    align-items: flex-start;
    /* 26, not 44. The introduction-to-visual gap on mobile is this padding plus
       .approach--static .approach-scene's own top padding below, and measured
       it was 44 + 66 = 110 against a shared target of 40 to 52. This pair now
       measures 26 + 20 = 46. */
    padding-block: 68px 26px;
    text-align: left;
  }
  .approach-preface-copy { max-width: 31rem; }

  .approach-pin-wrap { min-height: 0; }
  .approach-scene { display: block; overflow: visible; padding-block: 0; }
  /* Stated at two classes on purpose. .approach--static .approach-scene above
     out-specifies the single-class rule on the line before regardless of this
     media query, so zeroing the padding there does nothing on a phone: it
     resolved to clamp(66px, 8vw, 96px) = 66 and was the real reason the
     introduction sat 110px above the brain. 20px top is the balance of the
     46px target. The bottom used to go to zero because .approach-close
     brought 60px of its own; that beat was removed 2026-08-06 (Jess: cut
     the "claim I'm willing to show my working on" line and its dead space),
     so this now carries its own closing space instead of relying on it. */
  .approach--static .approach-scene { padding-block: 20px 32px; }
  .approach-scene-grid {
    display: flex;
    flex-direction: column;
    gap: 46px;
    align-items: stretch;
    padding-block: 0;
  }
  /* The desktop nudge is a rightward shift inside a two-column grid; stacked
     single-column mobile has no "right" to shift toward, so it is reset. */
  .approach-copy-column { padding-left: 0; }
  .approach-header-paper h2 { font-size: clamp(2.3rem, 11vw, 3.25rem); }
  .approach-header-paper > p:last-child { font-size: 1.0625rem; }

  .approach-brain-column {
    order: -1;
    min-height: auto;
    margin-bottom: 20px;
  }
  .approach-brain-stage { width: min(88vw, 430px); }
  .approach-brain-hud { top: -9%; right: 0; font-size: .688rem; }
  .approach-brain-tag { font-size: .688rem; }
  .approach-brain-caption { bottom: -32px; font-size: 1rem; }

  .approach-mechanisms {
    margin-top: 42px;
    /* Daniel, item 10 (found in the 2026-07-30 mobile report, briefed to every
       section): "the card sizes across the board could be smaller... especially
       the width, we need to see the hard work we put in the background
       animations." Narrowed from the column's full width -- effectively about
       90vw once .container's own --gutter is subtracted at 390 -- down to a
       literal 84vw, inside the requested 82-86% band, so the Cell Drift field
       reads clearly along the right edge of the list instead of sitting mostly
       behind the reading column. Capped in px so a wider phone still inside
       this <=768px bucket does not scale the list past a sensible reading
       width. LEFT-ALIGNED ON PURPOSE, not centred: the kicker, heading and lead
       above keep their own full width and this list shares their left edge, so
       "narrower" reads as a deliberate indent rather than a block that has come
       loose from the copy above it. Done AFTER Job 1's highlight wiring and
       verified against mechanisms.css:434's load-bearing 100svh comment first --
       narrowing only ever changes MOBILE wrapping (min-height:0 here already),
       never the desktop pin geometry that comment protects. */
    width: min(84vw, 400px);
  }
  /* display:none REMOVED 2026-07-30 (mobile remediation, Job 1). Nothing
     drove setActive()/syncHighlight() below desktopQuery before this pass --
     buildPin() took the static branch unconditionally -- so an always-blank
     highlight was better than a highlight stuck on mechanism 0 forever.
     mechanisms.js now runs its own IntersectionObserver-driven highlight on
     this viewport (buildMechanismObserver()), so the panel has something
     real to show. Still hidden under prefers-reduced-motion, in the
     separate media query below -- that rule is untouched. */
  .approach-mechanism,
  .approach-mechanism.is-active {
    opacity: 1;
    transform: none;
  }
  /* Tighter internal padding to match the narrower card, item 10: was
     24px 0 26px 22px. The accent bar (.approach-mechanism-bar) needs no
     matching change -- it is positioned top:20px/bottom:20px and left with no
     explicit height, so it already stretches to fill whatever height this
     padding leaves, on every viewport. */
  .approach-mechanism { padding: 18px 4px 20px 16px; }
  .approach-mechanism-bar,
  .approach-mechanism.is-active .approach-mechanism-bar { transform: scaleY(1); }
  .approach-mechanism h3 { font-size: clamp(1.56rem, 7vw, 1.9rem); }
  .approach-mechanism > p:last-child { max-width: none; font-size: 1.0625rem; }
}

@media (prefers-reduced-motion: reduce) {
  .approach { --approach-fill: 100%; --approach-fill-n: 1; }
  .approach-active-highlight { display: none; }
  .approach-mechanism,
  .approach-mechanism.is-active { opacity: 1; transform: none; }
  .approach-mechanism-bar,
  .approach-mechanism.is-active .approach-mechanism-bar { transform: scaleY(1); }
  /* Explicit, rather than relying on base.css's global animation-duration
     override to neutralise it. Every ambient layer here stays VISIBLE; it just
     stops moving. That override collapses a keyframe animation to 0.01ms with
     one iteration, which parks the element on its LAST frame -- and the rings'
     last frame is opacity 0, so they would silently disappear. Each looping
     layer therefore declares its own resting state, at the MIDPOINT of its
     cycle rather than at either endpoint. */
  .approach-brain-aura { animation: none; }
  /* .approach-cells is a <canvas>, painted by mechanisms.js rather than by a
     CSS animation, so there is nothing here for this query to neutralise --
     the JS itself checks the same prefers-reduced-motion query and draws
     exactly one still frame (fixed clock, no drift, no pulse) instead of
     starting its render loop. See initCellDrift(). */
  .approach-rings::before,
  .approach-rings::after {
    animation: none;
    opacity: .34;
    transform: translate(-50%, -50%) scale(1.04);
  }
}

@media (prefers-reduced-transparency: reduce) {
  .approach-active-highlight {
    background: rgba(255, 250, 246, .96);
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }
}
