/* ══════════════════════════════════════════════════════════════════════════════════════
   Coldfront scroll engine — the stylesheet half.  Pairs with site/scroll.js.

   EVERY RULE THAT HIDES OR MOVES ANYTHING IS GATED BEHIND `html.cf-on`, and only script
   adds that class.  That single gate is the whole no-JavaScript story: with JS off, none
   of this file applies, no element is at opacity 0, no section is a pin spacer three
   screens tall, and the page is an ordinary document.  Nothing below may be written as a
   bare selector — if a rule can hide content without script having run, it is a bug.

   ────────────────────────────────────────────────────────────────────────────────────────
   CF_BOOT — the inline snippet that goes in <head>, AFTER this stylesheet and BEFORE
   scroll.js.  Copy it verbatim.

     <script>
     (function(d){var r=d.documentElement;try{
       if(matchMedia('(prefers-reduced-motion:reduce)').matches)return;
       if(r.getAttribute('data-cf-motion')==='reduce')return;
       r.className+=' cf-on';
       setTimeout(function(){if(!window.__cfReady)r.className=r.className.replace(/\bcf-on\b/,'')},2500);
     }catch(e){}})(document);
     </script>

   Why it has to be inline and synchronous: the class must land BEFORE first paint, or the
   browser paints the reveals in their final position and the engine then yanks them back
   to translateY(70px) on the first frame — a visible flinch on every load.  An external
   file, even a blocking one, is a round trip this cannot afford.

   Why the setTimeout: if scroll.js 404s or is blocked, `cf-on` would otherwise leave the
   page's content at opacity 0 forever.  The failsafe removes the class after 2.5 s unless
   the engine has signed in.  A stylesheet that loads and a script that does not must
   degrade to a readable page, not a blank one.

   Why the reduced-motion check is in the snippet as well as the engine: a visitor who has
   asked for less motion should not get one frame of hidden content either.
   ══════════════════════════════════════════════════════════════════════════════════════ */


/* ── reveals ───────────────────────────────────────────────────────────────────────────
   The engine writes `transform` and `opacity` as inline styles.  All this file does is
   set the pre-paint state and make sure nothing else on the element fights it.

   `data-cf-opacity="1"` means "move but never fade", so that one is excluded from the
   hide — otherwise a movement-only reveal would be invisible until the first frame. */
html.cf-on [data-cf-reveal]:not([data-cf-opacity="1"]),
html.cf-on [data-cf-from*="o:0"],
html.cf-on [data-cf-keyframe] { opacity: 0 }

/* No CSS transition anywhere on an animated element.  The value the engine writes IS the
   frame's value; a transition on top of it would add lag on the way down and, worse,
   asymmetric lag on the way back up — which is precisely the feel this engine exists to
   avoid.  Position is the clock. */
html.cf-on [data-cf-reveal],
html.cf-on [data-cf-parallax],
html.cf-on [data-cf-parallax-x],
html.cf-on [data-cf-parallax-scale],
html.cf-on [data-cf-from],
html.cf-on [data-cf-keyframe],
html.cf-on [data-cf-pin-progress],
html.cf-on [data-cf-track] { transition: none !important; animation: none !important }

/* ── a reveal that owns its own layer ──────────────────────────────────────────────────
   THE DEFAULT IS NO LAYER, AND THAT DEFAULT IS RIGHT — see write()'s comment.  But a
   non-promoted reveal paints into the document's shared tiled backing store, and while it
   travels its ink lands up to data-cf-distance px OUTSIDE its own border box.  Later
   repaints are driven off the element's box, so ink that landed outside it is orphaned:
   where it falls on a strip no element paints — a heading's bottom margin, over a section
   with no background — nothing can clear it, and it sits there as a faint half-clipped
   duplicate that tracks the real line down the page.  Filmed on a real iPhone, playcold-
   front.com, 2026-08-14: "the ground in between" and "THE LOOP", both at alpha 0.165 and
   both 39px below their crisp selves — 46*(1-0.165), the engine's own reveal arithmetic.

   With a layer of its own the element's ink goes into ITS backing store, the compositor
   moves the whole layer, and the page tile behind it never holds the ink at all.  The
   stale-paint hole is closed by construction rather than by hoping an invalidation is
   generous.  KEEP THE LAYER FOR THE LIFE OF THE PAGE: dropping will-change at p=1 would
   destroy the layer and hand its contents back to the shared tile, which is a fresh
   invalidation risk at the exact moment the element stops being watched.  This is opt-in
   per element for a reason — the cost is one backing store each. */
html.cf-on [data-cf-layer] { will-change: transform }

/* Transform on an inline box does nothing.  Rather than make every author remember, the
   engine's targets are given a block-ish display only when they are inline to begin with.
   `display:revert-layer` is not portable enough, so this is the pragmatic form: authors
   put reveals on block elements, and an inline one gets promoted here. */
html.cf-on a[data-cf-reveal], html.cf-on span[data-cf-reveal],
html.cf-on em[data-cf-reveal], html.cf-on strong[data-cf-reveal],
html.cf-on i[data-cf-pin-progress] { display: inline-block }


/* ── parallax layers ───────────────────────────────────────────────────────────────────
   A parallax plate moves relative to its own box, so it will overhang its container by up
   to half its amplitude at each end.  The container clips it; the plate itself is oversized
   by the author (a 110%-tall image inside a 100%-tall frame) so no gap can appear. */
html.cf-on [data-cf-parallax],
html.cf-on [data-cf-parallax-x],
html.cf-on [data-cf-parallax-scale] { will-change: transform; backface-visibility: hidden }


/* ── pinning: spacer + sticky child ────────────────────────────────────────────────────
   THE SPACER IS THE SCROLL DISTANCE.  `data-cf-pin="220"` gives the section one screen for
   the stage plus 2.2 screens of extra travel; the engine writes that number into
   --cf-pin-vh once, at measure time, never per frame.  The sticky child does not move at
   all — the pixels that change are the keyframe children underneath it, each of which owns
   a sub-range of the pin's progress.

   This is why the pattern is a PAIR rather than `position:fixed`: sticky keeps the stage in
   normal flow, so it releases by itself at both ends with no class toggling, no jump, and
   no interaction with the browser's own scroll restoration.

   100svh, not 100vh: on iOS the URL bar shrinking mid-scroll changes vh, which would move
   a pinned stage under the visitor's thumb.  svh is the small (bar-visible) height and
   does not change while scrolling. */
html.cf-on [data-cf-pin] {
  position: relative;
  min-height: calc(100vh + var(--cf-pin-vh, 100) * 1vh);
  min-height: calc(100svh + var(--cf-pin-vh, 100) * 1svh);
}
html.cf-on [data-cf-pin-stage] {
  position: sticky;
  top: var(--cf-pin-top, 0px);
  height: 100vh;
  height: calc(100svh - var(--cf-pin-top, 0px));
  overflow: hidden;
  display: grid;
  place-items: center;
  will-change: transform;
}
/* Every keyframe child occupies the same grid cell, so they stack and cross-fade in place
   instead of stacking vertically.  Under `html:not(.cf-on)` they are plain block children
   in normal flow and read top to bottom — which is the correct no-JS presentation of
   "three things this section says". */
html.cf-on [data-cf-pin-stage] > [data-cf-keyframe] { grid-area: 1 / 1; width: 100% }
/* ⚠ THE ART MUST NOT PAINT OVER THE HEADLINE. With every keyframe in cell 1/1 the head, the
   art and the copy all share one cell, and at mid-widths the image edge cut the glyph bottoms
   of the h2 it was illustrating (measured: 45px overlap at 1440x760, 39px at 1280x800, clean
   at 1920 and on mobile — so five static screenshots missed it and only a driven scroll
   found it). Three explicit rows: head, art, copy. The art row is minmax(0,1fr) so it takes
   what is left and never forces the others out. */
html.cf-on [data-cf-pin-stage] { grid-template-rows: auto minmax(0, 1fr) auto }
html.cf-on [data-cf-pin-stage] > .pinhead { grid-row: 1; align-self: start }
html.cf-on [data-cf-pin-stage] > .pinart  { grid-row: 2; min-height: 0; align-self: center }
html.cf-on [data-cf-pin-stage] > .pinart img { max-height: 100%; width: auto; object-fit: contain }
html.cf-on [data-cf-pin-stage] > .pinsay  { grid-row: 3; align-self: end }

html.cf-on [data-cf-pin-progress] { transform-origin: 0 50%; will-change: transform }
html.cf-on [data-cf-track] { will-change: transform; backface-visibility: hidden }


/* ── refusals ──────────────────────────────────────────────────────────────────────────
   THE RULES ABOVE GATE ON THE ATTRIBUTE, and they have to: they run before the script does,
   so they cannot know whether the engine will accept the element. When it does not — a pin
   with no stage child, a keyframe outside a stage — the engine adds `cf-inert`, and these
   two rules undo the parts that would otherwise fail LOUDLY and SILENTLY at the same time:
   an orphaned keyframe stuck at opacity 0 is invisible copy, and an orphaned pin keeps a
   three-screen spacer the visitor scrolls through for nothing. A markup mistake should cost
   an author a console warning, never cost a visitor the content. */
html.cf-on [data-cf-keyframe].cf-inert { opacity: 1 }
html.cf-on [data-cf-pin].cf-inert { min-height: 0 }


/* ── theme flip ────────────────────────────────────────────────────────────────────────
   The engine sets html.cf-theme-<name> from whichever [data-cf-theme] section owns the
   viewport's midpoint.  THE HOOK IS DEFINED HERE, THE COLOURS ARE NOT — a page's palette is
   the page's business, and an engine that ships colours becomes a framework.  The
   transition is on the page chrome, not on any scroll-driven element, so it does not
   violate the no-transition rule above. */
html.cf-on { transition: background-color .45s ease, color .45s ease }


/* ══════════════════════════════════════════════════════════════════════════════════════
   REDUCED MOTION — the backstop.

   The engine already tears its own inline styles out when the media query flips, at
   runtime, without a reload.  This block exists so that the promise holds even if the
   engine never gets the chance: a script that fails to parse, a CSP that blocks it, an
   error thrown before applyMotion(), a stale cached scroll.js.  `!important` is deliberate
   and necessary — it has to beat inline styles that a half-initialised engine may already
   have written.

   The pin spacers collapse too, so a reduced-motion visitor does not scroll through three
   empty screens per pinned section to reach the next one.
   ══════════════════════════════════════════════════════════════════════════════════════ */
@media (prefers-reduced-motion: reduce) {
  html [data-cf-reveal], html [data-cf-parallax], html [data-cf-parallax-x],
  html [data-cf-parallax-scale], html [data-cf-from], html [data-cf-keyframe],
  html [data-cf-pin-progress], html [data-cf-track] {
    transform: none !important;
    opacity: 1 !important;
    transition: none !important;
    animation: none !important;
  }
  html [data-cf-pin] { min-height: 0 !important }
  html [data-cf-pin-stage] {
    position: static !important;
    height: auto !important;
    overflow: visible !important;
    display: block !important;
  }
  html [data-cf-pin-stage] > [data-cf-keyframe] { grid-area: auto !important }
  /* Nothing moves on this path, so nothing needs a layer.  data-cf-layer exists to stop
     a MOVING element orphaning ink in the shared tile; with transform pinned to none the
     hole it closes cannot open, and the backing stores would be pure cost. */
  html [data-cf-layer] { will-change: auto !important }
  html.cf-on { transition: none !important }
}

/* The same, for the explicit override — a site-level "reduce motion" switch, and the seam
   a test harness uses to prove the reduced path without touching the OS setting. */
html[data-cf-motion="reduce"] [data-cf-reveal],
html[data-cf-motion="reduce"] [data-cf-parallax],
html[data-cf-motion="reduce"] [data-cf-parallax-x],
html[data-cf-motion="reduce"] [data-cf-parallax-scale],
html[data-cf-motion="reduce"] [data-cf-from],
html[data-cf-motion="reduce"] [data-cf-keyframe],
html[data-cf-motion="reduce"] [data-cf-pin-progress],
html[data-cf-motion="reduce"] [data-cf-track] {
  transform: none !important;
  opacity: 1 !important;
  transition: none !important;
  animation: none !important;
}
html[data-cf-motion="reduce"] [data-cf-pin] { min-height: 0 !important }
html[data-cf-motion="reduce"] [data-cf-pin-stage] {
  position: static !important;
  height: auto !important;
  overflow: visible !important;
  display: block !important;
}
html[data-cf-motion="reduce"] [data-cf-pin-stage] > [data-cf-keyframe] { grid-area: auto !important }
html[data-cf-motion="reduce"] [data-cf-layer] { will-change: auto !important }
