/* ============================================================================
   PSC logo intro — hold, release, land, shimmer.
   Pairs with public/js/logo-intro.js. Full rationale:
   plans/2026-09-12-logo-intro-handover.md

   NOTHING HERE PAINTS UNTIL JS ADDS .intro-armed TO <html>. That is deliberate:
   if the script never runs, the hero is an ordinary visible hero.
   ============================================================================ */

/* The flying mark, and the resting mark once it lands. background-image, -size and -position
   are all set per frame from JS, because they depend on the active quality tier.
   mix-blend-mode:screen is what lets a sprite on true black sit on the page with no alpha
   channel — which dodges the HEVC-alpha-for-Safari / VP9-alpha-for-Chrome problem entirely. */
.intro-mark {
  background-repeat: no-repeat;
  mix-blend-mode: screen;
  /* the sheets carry a little of the render's own halo at the cell edge; this feathers it so
     there is never a visible square */
  -webkit-mask-image: radial-gradient(closest-side, #000 78%, transparent 100%);
          mask-image: radial-gradient(closest-side, #000 78%, transparent 100%);
}

/* In flight. */
.intro-bloom, .intro-mark:not(.intro-mark--rest) { pointer-events: none; }
body > .intro-mark {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 60;
  pointer-events: none;
  /* The flight is translate + scale now, not left/top/width/height — see place() in the JS for
     why. `transform-origin: 0 0` is load-bearing: with the default centre origin, scaling also
     moves the element, so position and size stop being independent and the maths falls apart. */
  transform-origin: 0 0;
  will-change: transform;
}
/* ▶ NO `will-change` HERE, DELIBERATELY. It was added with the transform rewrite, by reflex,
   and it is wrong on this element: `mix-blend-mode: screen` already forces its own compositing
   layer, so the hint buys nothing and instead PINS that layer in memory for the element's whole
   life — including the landing, which is the one moment the compositor is busiest. will-change
   is a promise to the browser that something will change soon; on a layer that is already
   promoted it is just a reservation. */
.intro-bloom { transform-origin: 0 0; }

/* THE BLOOM THAT LEAVES THE BOX. A sprite can only carry as much glow as its own cell, so the
   spill is the site's own mechanism instead — the same radial gradient, --accent and
   --fx-glow-strength as .emblem::before — sized off the mark and free to extend past it.
   It fades rather than switching off, and starts giving way before the mark stops, so the two
   glows cross over instead of one being cut. */
.intro-bloom {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 59;
  pointer-events: none;
  --lift: 1;
  /* ▶ .55s -> .825s -> .65s -> 0s, AND THE ZERO IS THE POINT. Do not "soften" this back.
     ==========================================================================================
     History: the exit was slowed twice on request — *"the bloom disappearing at the end feels a
     bit sudden"* — and every slowdown made the landing worse, which read as lag and then as a
     flash. The reason only became clear once the FLIGHT bloom and the RESTING bloom
     (`.emblem::before`) were measured as one number instead of separately.

     They overlap. The resting bloom arrives at full strength the instant the mark stops — it has
     to, because fading it in leaves a gap where there is no glow at all, which is the reported
     "it stops, disappears and comes back". So if the flight bloom is ALSO still lit, both glows
     are on screen together and the total brightness doubles for as long as the fade lasts.

     Measured as total glow (flight + resting) across the landing, peak and biggest single-frame
     dip:
         1.0s exit  peak 1.87   dip -0.243
         .65s exit  peak 1.79   dip -0.294     <- what was shipping; this is the flash
         .2s exit   peak 1.45   dip -0.152
         0s exit    peak 1.00   dip -0.027     <- flat, and what is set below

     A slower exit does not soften the handover, it lengthens the double-exposure. There is
     nothing to fade because nothing goes away: the resting bloom is already sitting in the same
     place at full strength, so the flight bloom's job ends the moment the mark stops.

     The removal timers in land() must still sit above this (they are at 800ms) — that rule was
     always about not truncating a fade, and it costs nothing to keep the node around.

     ▶ THE SECOND REASON, which is the one nobody would guess: this transition was also applied
     DURING the flight, and there it was pure harm. `place()` sets `bloom.style.opacity` on EVERY
     FRAME — the per-frame updates ARE the animation. A transition on top of a per-frame
     animation does not smooth it, it lags it: each new value spent .65s easing toward a target
     that had already moved on, so the glow tracked roughly 650ms behind the curve that was
     authored for it. Measured just before the landing, the bloom read 0.83 with the transition
     against a designed 0.36 — more than twice as bright as intended, for the whole flight.
     Removing it makes the glow follow the actual curve. If the spin looks dimmer at the end than
     it used to, that is why, and the CONFIG glow/gstr numbers are the honest place to change it
     — not this line. */
  transition: opacity 0s;
  mix-blend-mode: screen;
  background: radial-gradient(closest-side,
              color-mix(in srgb, var(--accent) calc(var(--fx-glow-strength) * var(--lift) * 100%), transparent),
              transparent 100%);
}

/* The one loading bar. Locked under the start position for the whole intro — it is page
   furniture, not part of the mark. (The client's clip has its own bar baked in; the frames are
   cropped above it, so there is exactly one bar on screen.) */
.intro-bar {
  position: fixed;
  z-index: 60;
  height: 3px;
  background: rgba(246, 246, 246, .16);
  transition: opacity .5s ease;   /* matched to the bloom's exit */
}
.intro-bar__fill { height: 100%; width: 0; background: #f6f6f6; }

/* ▶ THE WAITING STATE, 2026-09-19. `.intro-waiting` is on the flier only while the sprite sheets
   are still decoding — logo-intro.js adds it on the first hold frame and removes it the moment the
   spin can begin.

   ▶▶ IT ANIMATES `opacity` AND NOTHING ELSE. THE FIRST VERSION ANIMATED `transform` AND THREW THE
   EMBLEM INTO THE CORNER.
   `place()` positions the flier by writing `flier.style.transform` — a translate3d plus a scale,
   recomputed every frame of the flight. A CSS ANIMATION BEATS AN INLINE DECLARATION while it is
   running, so a keyframe touching `transform` discarded the positioning outright and the mark
   painted at its untransformed origin: small, and up in the top-left corner. Uriah photographed it
   within minutes ("loading state in wrong place").
   The file already carries this warning about `.intro-in` a few hundred lines up — *"an animation
   beats a declaration during its active phase"* — and it applies with more force here, because
   `place()` is the only thing standing between the flier and the corner.
   **Never animate `transform` on `.intro-mark`. Its transform belongs to place().**

   ▶ A FADE, WHICH IS ALSO WHAT WAS ASKED FOR: *"could we also have a simple fade in/out flashing
   while in loading state"*. It says "alive, waiting" without competing with the bar that is
   already reporting real progress, and opacity animates on the compositor without needing
   `will-change` — which matters, because a layer torn down inside a clipping ancestor is the pair
   that stopped iOS repainting the hero on 2026-09-17. */
@keyframes intro-wait-pulse {
  /* ▶ DEEPER AND SNAPPIER, 2026-09-19 — Uriah: *"the fade could be more intense on either end"*.
     The floor drops 0.42 -> 0.12 so it nearly leaves the screen, and the peak is HELD rather than
     touched: 40% of the cycle sits at full and only the travel between is eased. A symmetric
     sine reads as a slow wobble; holding both ends reads as a deliberate pulse. */
  0%,  8%   { opacity: var(--fx-intro-wait-min, 0.12); }
  42%, 58%  { opacity: 1; }
  92%, 100% { opacity: var(--fx-intro-wait-min, 0.12); }
}
.intro-mark.intro-waiting {
  animation: intro-wait-pulse var(--fx-intro-wait-rate, 2.1s) ease-in-out infinite;
}
@media (prefers-reduced-motion: reduce) {
  .intro-mark.intro-waiting { animation: none; opacity: 1; }
}

/* The hero copy, the header and the ticker, hidden only while the intro is armed. */
html.intro-armed [data-intro-hold] { opacity: 0; }
/* ▶ THE REVEAL IS STAGGERED TOP TO BOTTOM, 2026-09-13.
   --intro-i is set by reveal() in logo-intro.js from the element's position in document order,
   so the ticker leads, then the header, the lockup, the lede and the buttons. The eye gets a
   path down the page instead of one flash it has to re-scan — which matters most on a phone,
   where there is no hover state doing that job later.

   ▶ EASING, NOT JUST DURATION. `ease` decelerates weakly and reads as a dissolve. The site's own
   settle curve — used on the cards, the reticles and the CTA arrow — starts fast and lands soft,
   so the element is legible early in its transition rather than half-there for most of it.
   Longer AND smoother, because a slow linear-ish fade feels slower than it is.

   ▶ EVERY VALUE IS A KNOB, AND 0 IS A VALID ONE. Setting --intro-stagger to 0s collapses this to
   the old behaviour without touching a rule, which is how it should be judged on a real device:
   cycle the number, don't rebuild the file. */
/* Declared on :root, NOT on [data-intro-hold], so the switchboard's knobs can reach them — the
   panel writes custom properties to the root and they inherit down. Set locally they would win
   over the knob and the sliders would appear to do nothing, which is the exact failure the
   switchboard's own rules 15 and 16 were written about. */
/* Tuned on a real browser 2026-09-13 — these are Uriah's numbers, not defaults anyone guessed.
   They are much slower than the values reasoned out beforehand (.72 / .085 / .1), which is the
   whole argument for the sliders: the harness could not run the transition at all, so every
   number before this one was arithmetic rather than judgement. */
:root {
  --intro-fade: 1.7s;
  /* ▶ THE HELD ELEMENTS GOT THEIR OWN FADE, 2026-09-26, AND IT DEFAULTS TO THE SHARED ONE.
     Uriah: *"the fades for the taglines themselves could also be 20% slower."* `--intro-fade` was
     doing two jobs — the hero's held elements HERE, and every band's cross-fade at
     `html.reveal-ready [data-scroll-reveal]` further down — so slowing the taglines by 20% would
     have slowed every fade on all fourteen pages by 20% as well. Two askings, one number.

     Splitting it costs nothing and changes nothing by default: unset, this IS `--intro-fade`, so
     every page behaves exactly as before until someone tunes it. Tuned, it moves the hero copy
     alone. Same shape as `--reveal-mode-block` splitting off `--reveal-mode` for the same reason. */
  --intro-hold-fade: 2.6s;   /* +20% from --intro-fade's 1.7s, 2026-09-26. Unset it back to
                                 `var(--intro-fade)` to re-couple them. */
  /* +30% from .335s, 2026-09-26 (Uriah: *"everything in the hero could have 30% more delay between
     them and same goes between the final hero element and the feature-panel if it's within the
     viewport"*). ONE knob answers both halves of that: the landing queue continues the intro's own
     numbering (`lead + i * stagger`), so the gap from the CTA to the first band widens by the same
     30% as the gaps inside the hero. `--reveal-gap` below is the lever if that one junction wants
     more than the others. */
  --intro-stagger: .56s;
  --intro-lead: .5s;
  /* Extra dwell between the CTA and the first already-visible band below it. 0s means the bands
     are simply the next elements in the intro's own sequence, which is the intended default;
     raise it if the handover wants a beat. See the landing-queue rule further down. */
  --reveal-gap: 0s;
}
[data-intro-hold].intro-in {
  opacity: 1;
  transition: opacity var(--intro-hold-fade) cubic-bezier(.22, 1, .36, 1)
              calc(var(--intro-lead) + var(--intro-i, 0) * var(--intro-stagger));
}

/* ▶ THE CTA ROW IS UNVEILED LEFT TO RIGHT, 2026-09-26 (second attempt).
   Uriah asked for *"some sort of sideways sweep for the hero buttons"*. The first attempt slid the
   row with `translateX` and he was right about the result: *"the buttons are visible the whole time
   and there still isn't a sweep, it's more of a left to right jank, I meant they were invisible and
   are unveiled."*

   ▶▶ WHY IT WAS VISIBLE THE WHOLE TIME, AND IT WAS A SPECIFICITY MISTAKE OF EXACTLY THE KIND THIS
   FILE WARNS ABOUT. `[data-intro-hold].intro-in` (0,2,0) owns `transition: opacity`. The first
   attempt added `.hero__cta[data-intro-hold]` — ALSO (0,2,0), and later in the file — declaring
   `transition: transform`. Equal specificity, so document order won and the opacity transition was
   REPLACED rather than extended. Opacity therefore snapped 0 → 1 the instant `.intro-in` landed and
   only the transform animated: buttons present immediately, then a slide. The previous comment here
   claimed to have avoided that trap. It did not.

   ▶ SO: A REAL UNVEIL, AND ONE RULE THAT CARRIES BOTH PROPERTIES. `clip-path: inset(0 100% 0 0)`
   hides the row by clipping everything right of its left edge; animating the right inset to 0 wipes
   it open left to right, which is what "unveiled" means — the buttons are not there and then they
   are, progressively. The selector is `.hero__cta[data-intro-hold].intro-in` (0,3,0), which OUTRANKS
   the shared rule rather than tying with it, and it restates the opacity transition alongside the
   clip-path so neither half can be lost. One rule, two properties, one delay.

   ▶ NO LAYOUT AND NO BLUR. A clip is a paint-time operation: it reflows nothing, so the fold
   contract is untouched, and unlike a mask it needs no blur — which matters because the iPhone 8
   renders none. `--intro-cta-sweep: 0s` disables the wipe and leaves the plain fade. */
/* ▶ EACH BUTTON WIPES FROM ITS OWN SIDE, 2026-09-26 (third attempt). Uriah: *"if it's possible for
   each button to have its own sweep that would be great, left to right for the left one and right to
   left for the right one. I know they're stacked on mobile but still want to see how it looks."*

   The row carries the OPACITY (from the shared `[data-intro-hold].intro-in` rule) and the buttons
   carry the WIPE. Keeping those on different elements is what stops the two declarations colliding —
   the mistake the previous attempt made, where a same-specificity `transition` on the row replaced
   the shared opacity transition and the buttons simply appeared before sliding.

   `inset(0 100% 0 0)` clips everything right of the left edge, so opening the RIGHT inset uncovers
   the button left-to-right. `inset(0 0 0 100%)` is its mirror and uncovers right-to-left. Both land
   at `inset(0 0 0 0)`.

   ▶ `:first-child` / `:last-child` RATHER THAN A CLASS, because the markup is two bare anchors
   (`.btn--primary` then `.btn`) and the direction is about POSITION in the row, not about which
   button is the primary one. If a third button ever appears, the middle one simply fades with the
   row and nothing breaks.

   ▶ STACKED ON A PHONE AND STILL CORRECT: the wipe axis is horizontal either way, so a stacked
   button uncovers across its own width. He asked to see it there too. */
html.intro-armed .hero__cta[data-intro-hold] > .btn:first-child { clip-path: inset(0 100% 0 0); }
html.intro-armed .hero__cta[data-intro-hold] > .btn:last-child  { clip-path: inset(0 0 0 100%); }
html.intro-armed .hero__cta[data-intro-hold] > .btn { opacity: 0; }
.hero__cta[data-intro-hold].intro-in > .btn {
  clip-path: inset(0 0 0 0);
  /* ▶ THE BUTTON FADES AS IT WIPES, 2026-09-26. Uriah: *"if it's possible to add a fade to the sweep
     of the buttons that would be awesome too."* Its own opacity, on its own element — the ROW's
     opacity still comes from the shared `[data-intro-hold].intro-in` rule, and the two multiply, so
     the button ends up fading twice over. That is the intent: the wipe uncovers a button that is
     itself arriving, rather than uncovering something already fully drawn. */
  opacity: 1;
  transition: clip-path var(--intro-cta-sweep, 1.15s) cubic-bezier(.22, 1, .36, 1)
                calc(var(--intro-lead) + var(--intro-i, 0) * var(--intro-stagger)),
              opacity var(--intro-cta-sweep, 1.15s) cubic-bezier(.22, 1, .36, 1)
                calc(var(--intro-lead) + var(--intro-i, 0) * var(--intro-stagger));
}
@media (prefers-reduced-motion: reduce) {
  /* A moving edge is movement, so it goes; the row's fade carries the arrival on its own. */
  html.intro-armed .hero__cta[data-intro-hold] > .btn,
  .hero__cta[data-intro-hold].intro-in > .btn { clip-path: none; opacity: 1; transition: none; }
}

/* Reduced motion keeps the fade — it is the thing that stops a hard cut — but drops the stagger,
   because a sequence is motion that leads the eye, and leading the eye is exactly what someone
   with vestibular sensitivity has asked not to have done to them. */
@media (prefers-reduced-motion: reduce) {
  :root { --intro-stagger: 0s; }
}

/* ▶ THE CSS FAILSAFE. THE PAGE UN-HIDES ITSELF EVEN IF JAVASCRIPT NEVER RUNS AGAIN.
   ==========================================================================================
   Every un-hide path was, until now, a JavaScript path: land(), finishInstantly(), the 8s
   watchdog, and the inline script's own 11s timer. They cover a lot — but they all share one
   assumption, that the script keeps running. An uncaught error anywhere after arming, a sprite
   that wedges a decode, an iOS tab frozen and restored mid-intro, a `pagehide` that cancels the
   rAF loop: any of those leaves `.intro-armed` on <html> forever, and with it a hero that is
   permanently invisible. Reported from real devices as a heading that is "non-existent".

   This removes that entire class of failure without asking JavaScript for anything. The
   animation runs on the hidden elements themselves, from the moment they are hidden, and ends
   at opacity 1. If the intro lands normally it is irrelevant — `.intro-in` transitions them
   visible long before, and `.intro-armed` comes off which cancels this outright. If the intro
   dies, this is the only thing left, and it still finishes.

   ▶ 12s here, 14s on a handheld (see the media query below), i.e. AFTER every JS path has had
     its chance. It was 12s everywhere, against "land at ~4.9s, the
     intro's watchdog at 8s" — numbers that stopped being true long before anyone re-ran the sum.
     The live ladder, 2026-09-19: sheets up to 5.5s · settle 1.35s · guaranteed spin 1.5s · flight
     2.8s = 11.15s worst case, watchdog 12.35s, and THIS at 14s. Reaching this one means all of
     them failed. **It must stay last; it is the only number in the ladder that is not derived, so
     it is the only one that can silently fall behind.**

   ▶ `backwards` matters as much as `forwards`. Without it the element would paint at its
     natural opacity for one frame before the animation's first keyframe applies — which is the
     flash, reintroduced by the thing meant to prevent a worse bug.

   ▶ IT MUST NOT FIGHT THE NORMAL REVEAL. `.intro-in` sets opacity in a plain declaration, and
     an animation beats a declaration during its active phase — so the rule below is scoped to
     `:not(.intro-in)`. The moment the real reveal happens the animation stops applying and the
     transition takes over cleanly. */
@keyframes intro-failsafe { from { opacity: 0; } 99% { opacity: 0; } to { opacity: 1; } }

html.intro-armed [data-intro-hold]:not(.intro-in) {
  animation: intro-failsafe 12s linear both;
}

/* The structural hide below has the same exposure and the same answer. */
@keyframes intro-failsafe-structural { from { opacity: 0; } 99% { opacity: 0; } to { opacity: 1; } }



/* ▶ NOTHING BUT THE EMBLEM, 2026-09-14. Uriah: *"Everything should be invisible but the emblem,
   and content should only appear as you scroll down closer to it."*

   `[data-intro-hold]` names the things that should FADE BACK IN on landing — header, ticker,
   lockup, lede, CTAs. That list can never be complete on its own, because it has to be kept in
   step by hand with every section anyone adds to the home page, and the failure is silent: a new
   band simply sits there visible through the intro and nobody notices until they watch it.

   So the rest is structural and works by exclusion: hide everything that is not on the path to
   the emblem. Three rules, and they cover any markup added later without being edited.

   ▶ IT RELEASES ITSELF. The script removes `.intro-armed` from <html> when the mark lands, so
   every rule here stops applying at once — there is no second list to maintain and nothing that
   can be left hidden by a code path that forgot to un-hide it. The watchdog that force-lands
   after 8s therefore also un-hides everything, for free.

   ▶ THE FRAME IS NOT CONTENT AND STAYS. The shell border and the corner registration marks (and, until
   2026-09-29, the CRT overlay) are the *screen*, not what is on it — hiding them would blank the identity and
   make the intro look like a broken page rather than a page still arriving. Reading "everything"
   as "all content" rather than "all pixels" is the interpretation that keeps the brand on screen
   while the copy is still on its way.

   ▶ BELOW THE FOLD, THIS IS BELT AND BRACES. Those sections are off-screen during the intro
   anyway; the rules matter for anyone who scrolls while it is playing, and they snap rather than
   fade on release because there is nothing to see the transition on. The fade is reserved for
   the things actually in view, which is what `[data-intro-hold]` is for. */
/* ▶ `:not([data-intro-hold])` ADDED 2026-09-13, and it is what lets the two lists coexist.
   This rule is the SNAP list — hidden while armed, restored instantly on release, on the stated
   grounds that "they snap rather than fade because there is nothing to see the transition on".
   That was true when everything it covered sat below the fold. It stopped being true once the
   featured band and What's On moved up: Uriah, 2026-09-13 — *"the stuff fed from the database
   appears instantly, destroying the illusion."*

   Anything marked [data-intro-hold] is opting into the FADE list instead, so it must leave this
   one. Without the exclusion it would match both, and this rule is the more specific (0,2,2
   against [data-intro-hold].intro-in's 0,2,0) — so the element would stay at opacity 0 after the
   reveal and never arrive at all. A more specific hide silently beating a less specific show is
   the worst failure available here, because it looks like the fetch failed.

   ▶▶ `.emblem-host` IS EXEMPTED TOO, AND THE OMISSION COST THE SNAPBACK, 2026-09-19.
   Stream A wrapped the emblem in `.emblem-host` (A2, so the glow's containment has somewhere to
   live that is not an ancestor of the hero copy). The wrapper became `.hero`'s direct child, it is
   not `.emblem`, so IT matched this rule and went to `opacity: 0` — **taking the emblem, the mark
   and the whole flight down with it**, because opacity on a parent multiplies. The emblem
   reappeared only when `intro-armed` came off at the landing, so the symptom was precise: every
   frame of the snapback missing, and the mark simply appearing at rest.
   Measured: the mark first reached opacity 1 at **4,894 ms** before the wrap and **5,201 ms**
   after it — 5,201 ms being exactly the landing.

   ▶ THE GENERAL RULE, because this is the second wrapper to break a selector in three days:
   when you wrap an element, check BOTH directions — the rules that point AT it (child combinators
   aimed at the element) **and the rules it was EXEMPTED FROM by position** (`:not()` carve-outs on
   its parent's children). The first was checked here and was clean. The second is what broke, and
   it is the easier one to miss, because the element being protected still matches its own
   exemption — it is just no longer in the position the exemption applies to. */
html.intro-armed .shell > *:not(main):not(.reg),
html.intro-armed main > *:not(.hero):not([data-intro-hold]),
html.intro-armed .hero > *:not(.emblem):not(.emblem-host):not([data-intro-hold]) {
  opacity: 0;
  animation: intro-failsafe-structural 12s linear both;
}

/* ── SCROLL REVEAL, ON EVERY ENGINE ──────────────────────────────────────────────────────────
   ▶▶ THIS BLOCK WAS SCOPED TO `@supports not (animation-timeline: view())` AND THAT WAS THE BUG.
   The reasoning it was written on is still correct as far as it goes, and is kept here because
   the fight it describes is real and comes straight back if `rise-in` is ever widened again:

     `@keyframes rise-in` declares only a `from`, so its `to` is the element's UNDERLYING value.
     A JS system that sets that underlying value to `opacity: 0` makes `rise-in` animate
     0.001 → 0 — invisible for the whole scroll — and then snap to 1 the instant `.revealed`
     changes it. No transition in either direction, inline styles making no difference, and a
     perfect A/B where the lede faded and the section did not.

   ▶ WHAT THE PREVIOUS FIX GOT WRONG. It resolved that by standing the JS down wherever `rise-in`
   exists and handing the native mechanism the job. But `rise-in` is SCROLL-PROGRESS and this is
   EVENT-TRIGGERED, and for a reveal those are not the same product:

     Scroll-progress has no "first time seen". Opacity is a function of position, so a section
     sitting in the viewport at rest is parked at whatever fraction its position implies —
     measured at 0.519, permanently — and a section already past `entry 45%` when the intro
     un-arms has ALREADY FINISHED, behind the intro's own curtain. It then appears instantly the
     moment the intro's `opacity: 0` stops applying, which is exactly the pop this exists to
     replace.

   ▶ SO THE FIGHT WAS REMOVED AT ITS SOURCE: `rise-in` stopped selecting `[data-scroll-reveal]`.
   **AND AS OF 2026-09-26 `rise-in` DOES NOT EXIST AT ALL** — it, `settle-in` and `rule-draw` were
   retired from brand.css, because the `@supports (animation-timeline: view())` they were gated on
   is FALSE on the iPhone 8 / iOS 16.7 this site is measured against, so six pages had no arrival
   animation whatsoever on the phone. The sentence that stood here — *"`rise-in` keeps the prose,
   the cards and every other page exactly as they were"* — was true when written and is now false;
   it is corrected rather than deleted, because a stale support note is exactly the habit this
   file's own comment warns about two hundred lines up.

   ▶ AND IT MAKES THE ENGINES AGREE. iOS forces every browser onto WebKit, which has no view()
   timeline, so the phone always ran this path. It is the behaviour that was already liked — and
   it is now the behaviour on every page and every engine, rather than a fallback no desktop ever
   reached and six pages never got at all. */
html.reveal-ready [data-scroll-reveal] {
  opacity: 0;
  transition: opacity var(--intro-fade, 1.7s) cubic-bezier(0.22, 1, 0.36, 1);
}
html.reveal-ready [data-scroll-reveal].revealed { opacity: 1; }

/* ▶ THE PRE-PAINT HOLD, 2026-09-27 (public/js/reveal-guard.js). Uriah, on /visit/: content "already
   there, then disappears and then fades in". On an uncached load the browser painted the page before
   the deferred scroll-reveal.js ran, then the script hid it. This keeps everything under the page
   title back from the first paint; the guard releases it at DOMContentLoaded, which is after
   scroll-reveal.js has armed `reveal-ready` above, so the hand-over is hidden to hidden with no frame
   in between. If scroll-reveal.js never loads, the release shows the page as it is: fail-open.
   Not on the home page (`:has(.hero)`): the intro already holds its hero, and its bands wait for it. */
html.reveal-pending main:not(:has(.hero)) > :not(.page-head) { opacity: 0; }

/* ▶ WHAT YOU HAVE ALREADY SCROLLED PAST DOES NOT ANIMATE. (2026-09-26)
   Set by `sweepPassed()` in scroll-reveal.js, for a target that is entirely ABOVE the viewport —
   one the scroll jumped over. An IntersectionObserver fires on first intersection only, so without
   this those blocks stay at opacity 0 forever; measured on /about/ at 375x559, a
   `scrollTo(bottom, 'instant')` left three of them hidden for the full 1.56s sampled. The End key,
   /visit/'s `#parking` fragment and a restored scroll position all do this.

   ▶ IT IS NOT AN ARRIVAL, SO IT MUST NOT LOOK LIKE ONE. That content was missed, not delivered —
   fading it in as you scroll back up would animate something that should simply have been there.
   Hence no transition, not merely no delay.

   ▶ AND IT IS **NOT** WHAT THE PAGE'S OPENING BLURB GETS. Uriah, 2026-09-26: *"the blurb for each
   page should still fade in, just instantly."* Arriving without WAITING and arriving without
   ANIMATING are different things, and the first version of this rule conflated them, so the blurb
   popped. The blurb now takes the ordinary path with a sequencer wait of zero — a full fade, no
   delay — and this class is reserved for the scrolled-past case alone.

   Both selectors are (0,3,1) like `.revealed` above, and this one comes later, so it wins on
   document order — deliberately, and stated because relying on order silently is how the
   disclosure's density rule failed to apply twice. */
html.reveal-ready [data-scroll-reveal].reveal-instant { transition: none; }
html.reveal-ready [data-scroll-reveal].reveal-instant [style*="--u"] { transition: none; }

/* ── LIVE CONTENT THAT LANDS AFTER ITS SECTION HAS ALREADY ARRIVED (2026-09-26) ─────────────
   What's On's cards and News's items are fetched, so they replace a "Loading…" line inside a
   section that has usually been revealed already — and used to pop in. src/lib/live-arrive.ts adds
   `.live-arrive` + `--arrive-i` only in that case (a section still waiting for its own reveal is
   left to it), and removes both on `animationend`.
   The rhythm is the prose pages' cascade on purpose: one step, one fade, one place to tune.
   An ANIMATION with only a `from`, so it ends at the element's own opacity and needs no JS to
   finish; `both` holds opacity 0 through the delay so a later card never flashes first.
   `.live-wait` is the News sequence's hold (`data-arrive="sequence"`): items that landed before
   their section was revealed stay hidden until it is, then take `.live-arrive` a beat after the
   lede. Hidden ONLY under `reveal-ready`, so nothing can be stranded by a script that never ran. */
html.reveal-ready .live-wait { opacity: 0; }
.live-arrive {
  animation: live-arrive var(--reveal-fade, 0.8s) ease both;
  animation-delay: calc(var(--arrive-base, 0ms) + var(--arrive-i, 0) * var(--reveal-cascade-step, 90ms));
}
@keyframes live-arrive { from { opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .live-arrive { animation: none; } }

/* ── THE SAME BAND, ARRIVING BY UNIT ──────────────────────────────────────────────
   `data-reveal-split` is written by scroll-reveal.js ONLY while a band is actually arriving, and
   only when a mode other than `section` is asked for. At rest the attribute does not exist, these
   rules do not apply, and the band is the plain cross-fade above.

   ▶ THE BAND ITSELF STOPS FADING WHEN ITS UNITS DO. Two fades over the same pixels — the band's
   and the units' — read as a slow smear rather than as copy arriving. The disclosure learned this
   as `:has(.dis-unit)`; here the attribute says it outright.

   ▶ THE DELAY IS A FRACTION OF ONE SPREAD, NOT A CONSTANT PER UNIT. `--u` is the unit's position
   from 0 to 1, so the LAST unit lands at `--reveal-spread` whether the band holds four paragraphs
   or nine hundred characters. A constant per-unit delay is what turns character mode into a
   minute-long crawl — the shape of mistake that makes a mode look broken when it is mis-scaled.

   ▶ `--reveal-base` CARRIES THE LANDING QUEUE. A band that was already on screen when the intro
   landed waits its turn (see `.reveal-queued` below); in split mode that wait cannot sit on the
   band's own transition, because the band no longer has one. It is added to every unit's delay
   instead, so the queue and the stagger compose rather than compete. */
html.reveal-ready [data-scroll-reveal][data-reveal-split] {
  opacity: 1;
  transition: none;
}
/* ▶ THE UNIT IS WHATEVER CARRIES `--u`, AND NOTHING ELSE IS GUESSED AT. reveal-units.js writes
   that property inline on every unit it produces — a generated span in line and character mode,
   the client's own block in paragraph mode — so one selector covers all three without this
   stylesheet knowing anything about the markup inside a band. */
/* ▶▶ THE TRANSITION LIVES ON `.revealed`, NOT ON THE HIDDEN STATE, AND THAT IS THE WHOLE RULE.
   Written the obvious way round — transition on the base rule, `opacity: 1` on `.revealed` — the
   stagger delay applies in BOTH directions, so the trip from 1 down to 0 is also delayed. In
   paragraph mode, where the units are the client's existing blocks and therefore start at 1, the
   browser reported a running CSSTransition with computed opacity still 1: it was sitting out a
   1.84s delay on its way DOWN when `.revealed` arrived a frame later and sent it back to 1. The
   copy never faded, it just appeared with the band — identical in a screenshot, and invisible to
   any check of the end state. Asking the element for `getAnimations()` is what settled it, after
   three plausible explanations in a row had been wrong.

   Hiding is instant; only arriving is timed. */
/* ▶ AND `animation: none`, WHICH IS THE SAME TRAP IN A FOURTH GUISE. The prose in these bands
   already carries `settle-in`, and a property whose value is produced by an ANIMATION cannot be
   transitioned out of: measured, the eyebrow and the lede sat at opacity 0.001 — `settle-in`'s
   own `from` — for the entire arrival while the units either side of them faded normally. So
   the copy would have been permanently invisible in paragraph mode on exactly the band that
   matters most, the newsletter. brand.css already does this for `[data-scroll-reveal] .card` for
   the identical reason; a unit is simply the next thing that needs it.
   The animation returns the moment the split is undone, because the attribute goes with it. */
html.reveal-ready [data-scroll-reveal][data-reveal-split] [style*="--u"] {
  opacity: 0;
  animation: none;
  transition: none;
}
html.reveal-ready [data-scroll-reveal][data-reveal-split].revealed [style*="--u"] {
  opacity: 1;
  animation: none;
  transition: opacity var(--reveal-fade, 1.05s) cubic-bezier(0.22, 1, 0.36, 1)
              calc(var(--reveal-base, 0s) + var(--u, 0) * var(--reveal-spread, 0.6s));
}

/* The landing queue, expressed for split mode. Same formula as `.reveal-queued` below, because it
   IS the same wait — it just has to land on the units rather than on a band that no longer
   transitions. Composed with the stagger above rather than replacing it. */
/* ▶ THE SECTION DIVIDERS ARRIVE WITH THEIR SECTION, 2026-09-27. Uriah: the hairlines on About and Visit
   *"are always visible, they need to be part of the fade reveal with all the text. Worth implementing on
   legal pages too."* A split band stays at opacity 1 while its units fade, so anything drawn by the band
   itself (the editorial divider is its ::before, the legal one its border-top) was on screen from the
   moment the band was staged. Each now waits for the band's first unit, the heading: the same fade and
   the same `--reveal-base`. Hidden instantly, like the units; once the band is unsplit these rules stop
   matching and the line simply stays. */
html.reveal-ready [data-page-kind="editorial"] .prose-block[data-reveal-split]::before {
  opacity: 0;
  transition: none;
}
html.reveal-ready [data-page-kind="editorial"] .prose-block[data-reveal-split].revealed::before {
  opacity: 1;
  transition: opacity var(--reveal-fade, 1.05s) cubic-bezier(0.22, 1, 0.36, 1) var(--reveal-base, 0s);
}
html.reveal-ready [data-page-kind="legal"] .prose-block[data-reveal-split] {
  border-top-color: transparent;
}
html.reveal-ready [data-page-kind="legal"] .prose-block[data-reveal-split].revealed {
  border-top-color: var(--rule-text);
  transition: border-top-color var(--reveal-fade, 1.05s) cubic-bezier(0.22, 1, 0.36, 1) var(--reveal-base, 0s);
}
html.reveal-ready .prose-block.reveal-instant::before,
html.reveal-ready [data-page-kind="legal"] .prose-block.reveal-instant { transition: none; }

html.reveal-ready [data-scroll-reveal][data-reveal-split].reveal-queued {
  --reveal-base: calc(var(--intro-lead, 0.5s) + var(--reveal-gap, 0s)
                      + var(--reveal-i, 0) * var(--intro-stagger, 0.335s));
}

/* Reduced motion never reaches here — scroll-reveal.js does not split at all under it, so the
   band keeps the plain, shortened cross-fade the media query below sets. Stated because the
   absence of a rule is not obviously a decision. */

/* ▶ THE LANDING QUEUE. A band already on screen when the intro lands waits its turn instead of
   arriving alongside the hero, so the page reads as one sequence running down it rather than two
   fades at once. `--reveal-i` is set by scroll-reveal.js and CONTINUES the intro's own numbering
   (it starts at the CTA's index + 1), so this is deliberately the same formula as
   `[data-intro-hold].intro-in` above — the bands are just the next elements in the same run, and
   every intro knob governs them too.

   `--reveal-gap` is the one number that is new: extra dwell between the CTA and the first band,
   for when "next in sequence" turns out to want a beat of its own. 0s is a valid setting and is
   the default, which makes this rule inert until someone decides otherwise.

   Only elements the script marks get this. Anything revealed by scrolling to it later has no
   `.reveal-queued`, so it fades the moment it is reached, with no inherited delay.

   SPECIFICITY: (0,3,1) against the (0,2,1) shorthand above, so the delay lands; the shorthand
   would otherwise reset it to 0s. */
html.reveal-ready [data-scroll-reveal].reveal-queued {
  transition-delay: calc(var(--intro-lead, 0.5s) + var(--reveal-gap, 0s)
                         + var(--reveal-i, 0) * var(--intro-stagger, 0.335s));
}

/* While the intro runs these must be hidden by a DECLARATION, not by the structural failsafe
   animation above — a property an animation is holding cannot be transitioned out of, which is
   the same trap in a third guise.

   ▶ THE `animation: none` HERE DOES NOT WIN, AND DOES NOT NEED TO. The structural rule is
   `html.intro-armed main > *:not(.hero):not([data-intro-hold])` at (0,3,2); this selector is
   (0,3,1), so the failsafe animation still applies while armed. Harmless, because BOTH rules
   are scoped to `html.intro-armed`: at un-arm the animation is removed outright rather than
   transitioned out of, and `start()` then spends a frame before observing so the browser commits
   a painted frame at a declaration-sourced 0. That declaration is what the transition runs from.
   Written down because the previous comment claimed this line removed the animation — and a rule
   believed to win while it is losing is the hardest kind of bug to see. */
html.intro-armed [data-scroll-reveal]:not(.revealed) { opacity: 0; animation: none; }

/* Reduced motion keeps the fade and shortens it. `prefers-reduced-motion` is about vestibular
   triggers — movement, parallax, zoom — and a cross-fade is the substitute the guidance
   recommends INSTEAD of motion, so removing it would leave the abrupt appearance it exists to
   avoid. Nothing here translates or scales. */
@media (prefers-reduced-motion: reduce) {
  html.reveal-ready [data-scroll-reveal] { transition-duration: 0.4s; }
}

/* THE RESTING STATE. The 3D mark becomes the hero, so the bone plate goes: the render is lit
   for black and dies on a cream ground.

   --rest-overflow paints the mark BEYOND the emblem's box. The layout box is untouched, which
   is what keeps fold-check.py and frame-check.py measuring the same thing they always did —
   they measure boxes, and this adds painted size only, exactly as .emblem::before does. */
.emblem.is3d { background: transparent; box-shadow: none; }
.emblem.is3d img { display: none; }

/* ▶ THE SLOW ROTATING EMBLEM, 2026-10-01 (public/js/emblem-rotator.js). It takes the landed mark's box
   (--rest-overflow, same crop, so it sits exactly where the intro used to land) and fades in once; the
   delay is set inline by the script to line up with the title's fade. Its sheets are opaque with the
   page's black baked in (scripts/build-emblem-rotator.py), so there is no blend mode here: the iPhone 8
   does not honour one reliably. The feather is the landed
   mark's, so the crop edge never shows. --rot-fade is the knob for how long the fade takes. */
/* 2026-10-29, the owners: it "appears too quickly". The old curve, cubic-bezier(.22, 1, .36, 1), reached
   most of its opacity in the first fifth of the 2.2 s, so the length barely showed; an even ease-in-out over
   3 s spreads the change across the whole fade (3.5 s was "a little slow", Uriah, the same evening). */
:root { --rot-fade: 3s; }   /* 1.4 s until 2026-10-01, then 2.2 s ("slightly slower"), then 3.5 s */
.emb-rot { position: absolute; inset: calc(-1 * var(--rest-overflow, 20%)); pointer-events: none;
           opacity: 0; background-repeat: no-repeat; background-size: 500% 500%;
           transition: opacity var(--rot-fade) cubic-bezier(.45, 0, .25, 1);
           -webkit-mask-image: radial-gradient(closest-side, #000 var(--fx-emb-feather, 86%), transparent 100%);
                   mask-image: radial-gradient(closest-side, #000 var(--fx-emb-feather, 86%), transparent 100%); }
.emb-rot.is-in { opacity: 1; }
/* The canvas drawing method (emblem-rotator.js, 2026-10-01): a canvas is a replaced element, so `inset`
   alone does not stretch it; it is sized to the same box explicitly. */
canvas.emb-rot { width: calc(100% + 2 * var(--rest-overflow, 20%)); height: calc(100% + 2 * var(--rest-overflow, 20%)); }
/* The skip path's pre-paint rules clear the plate before the script runs; with the rotator the emblem
   box is simply empty until the fade starts. */
.emblem.has-rot { background: transparent; box-shadow: none; }
.emblem.has-rot img { display: none; }
.emblem.has-rot::before, .emblem.has-rot::after { display: none; }
/* ▶ THE TEXT MUST PAINT ABOVE IT. The rotator is positioned and paints past its box (--rest-overflow), and
   a positioned element paints over every NON-positioned block, whatever the document order. Its
   background is the page black (opaque; see the build script), so the eyebrow and the lede under that
   area vanished (measured 2026-10-01: the eyebrow at y 341-366 inside the rotator's -155..577). Making
   the hero's text blocks positioned puts them back in document order, after the emblem, so they paint
   on top. Scoped to the rotator so the old landing's layering is untouched. */
.hero:has(.emblem.has-rot) :is(.hero__lockup, .lede, .hero__cta) { position: relative; }
@media (prefers-reduced-motion: reduce) { .emb-rot { transition-duration: .6s; } }

/* ▶ THE OLD FLAT PLATE MUST GO THE MOMENT THE PAGE ARMS, NOT WHEN THE SCRIPT GETS ROUND TO IT.
   ==========================================================================================
   Uriah, 2026-09-14, after the hardening deploy: *"even after clearing the Hostinger and my
   browser's cache, I still get the flash before loading; the old emblem shouldn't even be there
   for now."*

   He is right, and the pre-paint fix did not cover this. That fix hides the CONTENT
   (`[data-intro-hold]` and the structural rules) before first paint — but the emblem is
   deliberately the one thing left visible, and at first paint its content is still the old flat
   bone plate `<img>`. Only `.is3d` hides that, and `.is3d` is added by the DEFERRED script. So
   the plate painted from first paint until logo-intro.js ran, then swapped to the 3D mark: a
   flash of the old emblem, exactly as described, and a completely different one from the
   whole-page flash already fixed.

   Hanging it off `.intro-armed` instead moves it to the inline pre-paint script, so the plate
   is gone before anything is painted.

   ▶ WHY THE ASSET STAYS ON THE SERVER. Deleting psc-chrome-snug.jpg would "fix" this by making
   the <img> a broken image instead of a plate, and would break the case this file's header
   exists to protect: with JavaScript off or blocked, nothing arms, `.intro-armed` is never set,
   the plate shows, and the hero is an ordinary working hero. The flat plate is the fallback, not
   a leftover.

   ▶ AND IT UN-HIDES WITH EVERYTHING ELSE. Because the selector is `.intro-armed`, the 11s inline
   failsafe and the 12s CSS failsafe both restore the plate if the intro dies before landing —
   so a dead intro leaves the old hero, never an empty box. */
html.intro-armed .emblem img { display: none; }

/* ▶ THE SKIP PATH, 2026-09-14. Exactly what `.emblem.is3d` does to the plate, applied before
   first paint instead of after the deferred script runs. `html.intro-skip` is set synchronously
   in index.astro's head when the intro is going to be skipped (a repeat visit, or reduced
   motion) — the case that used to `return` and therefore painted the old bone plate, its ring
   and its lift shadow until `.is3d` arrived.

   It deliberately does NOT hide anything else. On this path no intro is coming to un-hide the
   page, so hiding content here would strand it — the exact failure `.intro-armed` needs its 11s
   failsafe to avoid. This clears the wrong picture and nothing more.

   ▶ THE EMBLEM IS BRIEFLY EMPTY, AND THAT IS THE TRADE. Between first paint and the deferred
   script inserting the resting mark there is now a gap showing nothing rather than a gap showing
   the wrong thing. Nothing wrong on screen is the lower-risk default; if the gap reads as a
   missing image, the fix is the universal-3D-still work already written up in
   plans/2026-09-12-logo-intro-handover.md §11, which paints the correct mark instead of none. */
html.intro-skip .emblem img { display: none; }
html.intro-skip .emblem { background: transparent; box-shadow: none; }

/* ▶ AND THE PLATE ITSELF, NOT JUST THE IMAGE ON IT.
   Uriah, 2026-09-14: *"the emblem still isn't there but the white box it sits on top of still
   is, there needs to be genuinely nothing."*

   Hiding the <img> was half the job. `.emblem` carries its own `background: var(--plate)` — the
   bone plate — plus a 1px ring and a lift shadow, and those are cleared only by `.is3d`, which
   the deferred script adds. So the image went and a cream box stayed, which is worse than
   leaving it alone: an empty plate reads as a broken image rather than as a page still arriving.

   `.is3d` still does this too, for the landed state. This is the same clearing moved earlier, to
   the moment the page arms, so there is no window in which the plate is visible alone.

   The bloom goes with it: `.emblem::before` is a radial glow sized to the plate, and a glow
   around nothing is a smudge. */
html.intro-armed .emblem {
  background: transparent;
  box-shadow: none;
}
/* ▶ AND THE PLATE'S SHINE, 2026-09-28. Uriah: *"before the animation starts, sometimes before the emblem
   appears, there is a faint square with what looks like a shine animation on it."* `.emblem::after` is the
   plate's overlay sweep, and only `.is3d` (added later, by the deferred script) turned it off, so it kept
   sweeping an empty, transparent box through the pre-flight. Cleared in the same two states that clear the
   plate; when a failsafe takes `.intro-armed` off, plate and shine come back together. */
html.intro-armed .emblem::after,
html.intro-skip .emblem::after { display: none; }
/* ▶ THE HIDE MUST BE INSTANT; ONLY THE LANDING GETS A TRANSITION. Read this before touching
   the three rules below — getting it wrong is visible within the first second.

   `.emblem::before` is the emblem's resting bloom, and it paints whether or not there is a mark
   in the box. Left alone it sits at the LANDING POSITION for the whole intro, giving the ending
   away before the mark has flown anywhere.

   This used to be handled with `content: none`, which worked by destroying the pseudo-element.
   That was removed on the theory that it also broke the landing fade — a transition needs a
   starting value on an element that already exists. The theory was TESTED AND DISPROVED: the
   rule was re-injected at runtime and the landing captured through CDP screencast (real
   compositor frames, not computed style), with and without it. The runs were indistinguishable
   — glow-ring span 11.58 vs 11.54, biggest single-frame change 91% in both. It is not the pop.

   But removing it caused a REAL regression, reported immediately: "there is a bloom circle at
   the start where the emblem will land". The cause is the transition, not the hiding. `is3d`
   goes on at ARM time, in the same frame as `intro-armed`, so if the `opacity: 0` rule carries
   `transition: opacity .6s`, the bloom does not vanish — it FADES OUT over 600ms, in place, at
   the landing spot, which is the one thing it must never do. `content: none` hid that by being
   instant.

   So: hide with opacity and no transition, and hang the transition on the LANDED rule instead,
   where it is the only thing that needs it. The element survives throughout, so the landing fade
   still has its starting value, and the intro-time hide is a single frame. */
.emblem.is3d::after { display: none; }          /* the plate's own overlay sweep */
.emblem.is3d .intro-mark { position: absolute; inset: calc(-1 * var(--rest-overflow, 20%)); }

.emblem.is3d::before { opacity: 0; transition: none; }
/* ▶ NO FADE-IN HERE, AND THAT IS THE POINT. A .6s ease was tried and it IS the reported bug:
   "it should look like it just stops, not stops, disappears and comes back."

   The flight carries its own bloom (`.intro-bloom`), which is still at nearly full strength when
   the mark stops and then fades out over .65s. This pseudo-element is the RESTING bloom that has
   to take over from it. Fading it IN from zero means that at the moment of the landing there is
   briefly no resting glow at all — measured at opacity 0.000 for the first ~100ms after landing,
   reaching 1.0 only around +600ms. The glow visibly leaves and comes back. The frame drops
   around the landing (116-133ms frames, present at the restore point too, so not a regression)
   stretch that gap out and make it unmissable.

   The restore point read 1.000 at the landing frame, because `content: none` had destroyed the
   element and a recreated element cannot transition. That accident was the correct look: the
   resting glow is already there when the flight's bloom starts to go, so the two overlap and the
   total never dips. Instant is not a shortcut here, it is the requirement. */
.emblem.is3d.landed::before { opacity: 1; transition: none; }

/* THE SHIMMER.
   The site's own sweep (.emblem::after) is mix-blend-mode:overlay, which works because it runs
   over the bone plate — overlay brightens light pixels and does almost nothing to dark ones. On
   near-black it is invisible. So this is a second copy of whatever frame the mark is showing,
   screen-blended, revealed only through a moving diagonal alpha gradient. Alpha masks work
   everywhere; luminance masks (mask-mode) do not, which is why it is two layers rather than one
   masked overlay. Same 6.5s and the same rest-in-the-cycle shape as .emblem::after, so the two
   shimmers on the page speak in one voice. */
.intro-shimmer { position: relative; }
.intro-shimmer::after {
  content: "";
  position: absolute;
  inset: 0;
  background-image: inherit;
  background-size: inherit;
  background-position: inherit;
  background-repeat: no-repeat;
  mix-blend-mode: var(--fx-emb-shim-blend);
  opacity: calc(var(--fx-emb-shim-strength) * var(--emb-live-shim) * var(--emb-r-str) * var(--emb-r-skip)
                * (1 - var(--fx-emb-shim-flux) * var(--fx-emb-shim-f)));
  -webkit-mask-image: var(--fx-emb-shim-shape);
          mask-image: var(--fx-emb-shim-shape);
  -webkit-mask-size: 280% 280%; mask-size: 280% 280%;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  /* ▶ THE POSITION IS DERIVED FROM ONE ANIMATED NUMBER, 2026-09-15. --fx-emb-shim-p runs 0 -> 1
     linearly over the whole cycle; the band reaches the end at --fx-emb-shim-travel and waits
     there, and --fx-emb-shim-ease blends linear toward a smoothstep. Keyframe selectors cannot
     take var(), so this is the only way the pause share can be a knob. See brand.css seams. */
  --_sweep-t: min(1, var(--fx-emb-shim-p) / min(1, var(--fx-emb-shim-travel) * var(--emb-r-travel)));
  --_sweep-e: calc(var(--_sweep-t) + var(--fx-emb-shim-ease) * (var(--_sweep-t) * var(--_sweep-t) * (3 - 2 * var(--_sweep-t)) - var(--_sweep-t)));
  -webkit-mask-position: calc(var(--fx-emb-shim-from) + (var(--fx-emb-shim-to) - var(--fx-emb-shim-from)) * var(--_sweep-e)
                             + var(--fx-emb-shim-wobble) * var(--fx-emb-shim-w)) 0;
          mask-position: calc(var(--fx-emb-shim-from) + (var(--fx-emb-shim-to) - var(--fx-emb-shim-from)) * var(--_sweep-e)
                             + var(--fx-emb-shim-wobble) * var(--fx-emb-shim-w)) 0;
  animation: intro-sweep var(--fx-emb-shim-rate) linear infinite var(--fx-emb-shim-direction),
             emb-shim-wob var(--fx-emb-shim-wobble-rate) ease-in-out infinite,
             emb-shim-flux var(--fx-emb-shim-flux-rate) ease-in-out infinite;
}
@keyframes intro-sweep {
  from { --fx-emb-shim-p: 0; }
  to   { --fx-emb-shim-p: 1; }
}

/* ▶ THE EMBLEM'S ARTEFACT LAYERS, 2026-09-15. All inert at their defaults — see the seams block
   in brand.css. Scoped to `.emblem` (the resting mark), never to `body > .intro-mark` (the flier).

   The MASK is on `.emblem .intro-mark`, not `.landed`, on purpose: the resting host is built
   during the hold so its layer is already rasterised at the landing (logo-intro.js, "BUILD THE
   RESTING MARK NOW"). A mask that changed at `.landed` would re-rasterise it on exactly that
   frame. At --fx-emb-scan 0 the second mask layer is fully opaque, i.e. the shipped radial alone. */
.emblem .intro-mark {
  -webkit-mask-image: radial-gradient(closest-side, #000 var(--fx-emb-feather), transparent 100%),
    repeating-linear-gradient(var(--fx-emb-scan-angle),
      #000 0, #000 calc(var(--fx-emb-scan-period) * 0.5),
      rgba(0,0,0,calc(1 - var(--fx-emb-scan))) calc(var(--fx-emb-scan-period) * 0.5),
      rgba(0,0,0,calc(1 - var(--fx-emb-scan))) var(--fx-emb-scan-period));
          mask-image: radial-gradient(closest-side, #000 var(--fx-emb-feather), transparent 100%),
    repeating-linear-gradient(var(--fx-emb-scan-angle),
      #000 0, #000 calc(var(--fx-emb-scan-period) * 0.5),
      rgba(0,0,0,calc(1 - var(--fx-emb-scan))) calc(var(--fx-emb-scan-period) * 0.5),
      rgba(0,0,0,calc(1 - var(--fx-emb-scan))) var(--fx-emb-scan-period));
  -webkit-mask-composite: source-in;
          mask-composite: intersect;
}
/* Flicker and jitter only once landed: during the hold the host sits at an inline opacity of 0,
   and an animation outranks inline style — it would show the mark early. Opacity and the
   independent `translate` property are both compositor-only, and the host is already its own
   layer (blend + mask), so a constant-value animation at the defaults adds no new layer. */
.emblem.landed .intro-mark {
  animation: emb-flicker var(--fx-emb-flicker-rate) steps(1, end) infinite,
             emb-jitter var(--fx-emb-jitter-rate) steps(1, end) infinite;
}
@keyframes emb-flicker {
  0%, 21%, 24%, 52%, 55%, 83%, 86%, 100% { opacity: 1; }
  22.5%, 84.5% { opacity: calc(1 - 0.45 * var(--fx-emb-flicker) * var(--emb-live)); }
  23%, 85%     { opacity: calc(1 - 0.15 * var(--fx-emb-flicker) * var(--emb-live)); }
  53.5%        { opacity: calc(1 - 0.3 * var(--fx-emb-flicker) * var(--emb-live)); }
}
@keyframes emb-jitter {
  0%, 100% { translate: 0 0; }
  31%      { translate: calc(var(--fx-emb-jitter) * var(--emb-live)) 0; }
  32%      { translate: calc(-0.6 * var(--fx-emb-jitter) * var(--emb-live)) calc(0.25 * var(--fx-emb-jitter) * var(--emb-live)); }
  33%      { translate: 0 0; }
  71%      { translate: calc(-1 * var(--fx-emb-jitter) * var(--emb-live)) 0; }
  71.6%    { translate: 0 0; }
}
/* The chromatic ghost: a second copy of the frame, colour-shifted and offset, screened over the
   mark so the edges carry a fringe. Exists only when --fx-emb-rgb-on is `""`. The glitch rides on
   the same layer: brief sideways tears through a horizontal slice. */
.emblem.landed .emb-ghost {
  position: absolute;
  inset: 0;
  mix-blend-mode: var(--fx-emb-rgb-blend);
  opacity: calc(var(--fx-emb-rgb) * var(--emb-live));
  translate: var(--fx-emb-rgb-split) 0;
  pointer-events: none;
  animation: emb-glitch var(--fx-emb-glitch-rate) steps(1, end) infinite;
}
.emblem.landed .emb-ghost::before {
  content: var(--fx-emb-rgb-on);
  position: absolute;
  inset: 0;
  /* A colour cut to the frame's shape (brightness = opacity), not a filtered copy: a filter chain
     that darkens is exactly what showed as dark rings on the phone. 35deg is where sepia sat, so
     the hue control keeps the meaning it had. */
  background-color: hsl(calc(35deg + var(--fx-emb-rgb-hue)) 90% 62%);
  -webkit-mask-image: var(--emb-src-alpha); mask-image: var(--emb-src-alpha);
  -webkit-mask-size: var(--emb-img-size); mask-size: var(--emb-img-size);
  -webkit-mask-position: var(--emb-img-pos); mask-position: var(--emb-img-pos);
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
}
@keyframes emb-glitch {
  0%, 100% { translate: var(--fx-emb-rgb-split) 0; clip-path: inset(0); }
  14%      { translate: calc(var(--fx-emb-rgb-split) + var(--emb-g-k1) * var(--fx-emb-glitch) * var(--emb-live)) 0; clip-path: inset(var(--emb-g1t) 0 var(--emb-g1b) 0); }
  15.5%    { translate: calc(var(--fx-emb-rgb-split) + var(--emb-g-k2) * var(--fx-emb-glitch) * var(--emb-live)) 0; clip-path: inset(var(--emb-g2t) 0 var(--emb-g2b) 0); }
  17%      { translate: var(--fx-emb-rgb-split) 0; clip-path: inset(0); }
  63%      { translate: calc(var(--fx-emb-rgb-split) + var(--emb-g-k3) * var(--fx-emb-glitch) * var(--emb-live)) 0; clip-path: inset(var(--emb-g3t) 0 var(--emb-g3b) 0); }
  64%      { translate: var(--fx-emb-rgb-split) 0; clip-path: inset(0); }
}

/* ▶ NEVER A FILTER AND A BLEND MODE ON THE SAME ELEMENT, 2026-09-16. Uriah, after the first staging
   deploy: *"on mobile all the effects are masking the words below as they play"* — fine on a PC
   and in DevTools' phone emulation, wrong on the phone. Every effect layer paints the frame's
   BLACK background too, and only its blend mode stops that black from covering what is under it;
   on a phone the emblem's painted box reaches down over the eyebrow and the wordmark.
   Mobile WebKit composites animated layers on the GPU, where a filter and a blend on one element
   is the combination it does not reliably honour. So each layer is now built the way the resting
   mark already was — blend, mask, opacity and transform on the OUTER element, the image and its
   filter on an INNER one — the structure that has always rendered correctly on the phone. And
   `--fx-emb-fx-touch` (Emblem › phones) switches every layer off on touch screens, as a lever that
   does not depend on this being the whole story.

   ▶ THE EFFECT LAYERS, 2026-09-15 (second pass). logo-intro.js builds .emb-fx beside the resting
   mark and copies the frame into --emb-img and the mark's rotate/squash into --emb-shape-t.

   ▶ .emb-fx ITSELF CARRIES NO TRANSFORM, OPACITY OR FILTER, AND MUST NEVER. Any of those makes it
     a stacking context, which ISOLATES its children's blending: they would screen against a
     transparent box instead of the page, and the black of the frame would paint as a black
     square. So the shape transform is applied to each child, where it is harmless. */
.emblem.is3d:not(.fx-live) { --emb-live: 0; }
.emblem.fx-live { --emb-live: 1; }

/* ▶ THE SHIMMER GATE, 2026-09-19 — the same shape, one beat earlier.
   `.shim-live` is added at the LANDING plus `--fx-emb-shim-delay`; `.fx-live` still waits for the
   page reveal to finish plus `--fx-emb-fx-delay`. Two classes rather than one so the shine can
   arrive with the mark while the blooms and the glitch still wait for the page to settle.

   ▶ MOVING AN EFFECT BETWEEN THE TWO IS A ONE-VAR EDIT: `--emb-live-shim` for the early group,
   `--emb-live` for the late one. Nothing else distinguishes them.

   Both fall back to their registered initial-value of 1, so an emblem that never gets either class
   — no JS, reduced motion, the skip path — shows the resting look at full strength rather than a
   dead one. That is the same fail-open the rest of the intro rests on. */
.emblem.is3d:not(.shim-live) { --emb-live-shim: 0; }
.emblem.shim-live { --emb-live-shim: 1; }
/* ▶▶ ONE `transition` FOR BOTH GATES, 2026-09-27. Each gate used to carry its own `transition`
   shorthand, and both classes sit on the same element — so the LATER rule (the shimmer's) replaced
   the glow's outright, and `--emb-live` jumped 0 -> 1 with no transition at all. That is why the
   glow "comes on real intense" (Uriah) no matter what `--fx-emb-fx-fade` said: measured on staging,
   the emblem's computed transition was `ease-out 0.3s` — the shimmer's alone. Listing both here means
   each property keeps its own duration and curve whichever class lands first. */
.emblem.shim-live, .emblem.fx-live {
  transition: --emb-live-shim var(--fx-emb-shim-fade, var(--fx-emb-fx-fade)) ease-out,
              --emb-live var(--fx-emb-fx-fade) ease-in-out;
}

/* ▶ THE GLOW IS CONTAINED HERE, NOT ON .hero — harden L2, 2026-09-19.
   The layers inside this box scale past it, and with the promoted glow values they reach beyond the
   VIEWPORT: measured 54px of page overflow at 320px and still 24px at 430px, i.e. the page scrolls
   sideways on every phone. The 2026-09-17 fix put `overflow-x: clip` on `.hero`, which stopped it
   and cost a day — a clipping ancestor plus a compositing-layer teardown inside it is an iOS
   repaint trap, and the hero text vanished on Uriah's phone.

   **`.emb-fx` is not an ancestor of the hero copy.** It is a decorative sibling layer inside
   `.emblem`, so clipping here cannot strand any text: there is no text in it, and nothing inside it
   has its `will-change` torn down. The glow still reaches the full width of this box — which is
   what `--rest-overflow` defines as its reach — and only the part that was already off the screen
   is cut.

   ▶ IF THE HALO EVER LOOKS TRUNCATED, raise `--rest-overflow` (the box) rather than removing this
   line (the containment). Verified at the time: 0px page overflow at seven widths in two engines,
   and the emblem's painted pixels within 1/255 of unclipped at 390px. */
.emblem.landed .emb-fx { position: absolute; inset: calc(-1 * var(--rest-overflow, 20%));
                         pointer-events: none; overflow: clip; }
.emb-fx > * {
  position: absolute; inset: 0; display: none; pointer-events: none;
  background-image: var(--emb-img);
  background-size: var(--emb-img-size);
  background-position: var(--emb-img-pos);
  background-repeat: no-repeat;
  transform: var(--emb-shape-t, none);
  -webkit-mask-image: radial-gradient(closest-side, #000 var(--fx-emb-fx-feather), transparent 100%);
          mask-image: radial-gradient(closest-side, #000 var(--fx-emb-fx-feather), transparent 100%);
}
/* ▶ A GLOW IS A COLOUR CUT TO A SHAPE, THEN BLURRED, 2026-09-16. After the transparent frames
   stopped the masking, the phone still showed the glows as DARK rings: their colour was made by
   CSS filters (grayscale, contrast, sepia) that darken mid-tones, and only the screen blend had
   been discarding that darkness. Now: .emb-glow__blur::before is a solid --*-color masked by a
   pre-built source (whole mark, bright chrome only, or outlines only — scripts/build-emblem-alpha.py
   does the threshold and edge detection once, as images), and .emb-glow__blur blurs it. No pixel
   can be darker than the chosen colour, with or without the blend mode, on any engine. */
.emb-glow__blur { position: absolute; inset: 0; }
.emb-glow__blur::before {
  content: ""; position: absolute; inset: 0;
  -webkit-mask-size: var(--emb-img-size); mask-size: var(--emb-img-size);
  -webkit-mask-position: var(--emb-img-pos); mask-position: var(--emb-img-pos);
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
}
.emb-glow--a {
  display: var(--fx-emb-glow-a-on);
  mix-blend-mode: var(--fx-emb-glow-a-blend);
  background: none;
  translate: var(--fx-emb-glow-a-x) var(--fx-emb-glow-a-y);
  scale: var(--fx-emb-glow-a-spread);
  opacity: clamp(0, var(--emb-live) * var(--fx-emb-glow-a) * (1 - var(--fx-emb-glow-a-breathe) * var(--emb-wa))
                    * (1 - var(--fx-emb-glow-a-drift) * var(--emb-wb))
                    + var(--emb-live) * var(--fx-emb-glow-a-flare) * var(--emb-fl), 1);
  animation: emb-wave-a var(--fx-emb-glow-a-breathe-rate) ease-in-out infinite,
             emb-wave-b var(--fx-emb-glow-a-drift-rate) ease-in-out infinite,
             emb-flare var(--fx-emb-glow-a-flare-rate) linear infinite;
}
.emb-glow--a > .emb-glow__blur {
  filter: blur(var(--fx-emb-glow-a-radius))
          drop-shadow(0 0 var(--fx-emb-glow-a-haze) color-mix(in srgb, var(--fx-emb-glow-a-color) calc(var(--fx-emb-glow-a-haze-amt) * 100%), transparent));
}
.emb-glow--a > .emb-glow__blur::before { background-color: var(--fx-emb-glow-a-color); -webkit-mask-image: var(--fx-emb-glow-a-src); mask-image: var(--fx-emb-glow-a-src); }
.emb-glow--b {
  display: var(--fx-emb-glow-b-on);
  mix-blend-mode: var(--fx-emb-glow-b-blend);
  background: none;
  translate: var(--fx-emb-glow-b-x) var(--fx-emb-glow-b-y);
  scale: var(--fx-emb-glow-b-spread);
  opacity: clamp(0, var(--emb-live) * var(--fx-emb-glow-b) * (1 - var(--fx-emb-glow-b-breathe) * var(--emb-wa))
                    * (1 - var(--fx-emb-glow-b-drift) * var(--emb-wb))
                    + var(--emb-live) * var(--fx-emb-glow-b-flare) * var(--emb-fl), 1);
  animation: emb-wave-a var(--fx-emb-glow-b-breathe-rate) ease-in-out infinite,
             emb-wave-b var(--fx-emb-glow-b-drift-rate) ease-in-out infinite,
             emb-flare var(--fx-emb-glow-b-flare-rate) linear infinite;
}
.emb-glow--b > .emb-glow__blur {
  filter: blur(var(--fx-emb-glow-b-radius))
          drop-shadow(0 0 var(--fx-emb-glow-b-haze) color-mix(in srgb, var(--fx-emb-glow-b-color) calc(var(--fx-emb-glow-b-haze-amt) * 100%), transparent));
}
.emb-glow--b > .emb-glow__blur::before { background-color: var(--fx-emb-glow-b-color); -webkit-mask-image: var(--fx-emb-glow-b-src); mask-image: var(--fx-emb-glow-b-src); }
.emb-edge {
  display: var(--fx-emb-edge-on);
  mix-blend-mode: var(--fx-emb-edge-blend);
  background: none;
  translate: var(--fx-emb-edge-x) var(--fx-emb-edge-y);
  scale: var(--fx-emb-edge-spread);
  opacity: clamp(0, var(--emb-live) * var(--fx-emb-edge) * (1 - var(--fx-emb-edge-breathe) * var(--emb-wa))
                    * (1 - var(--fx-emb-edge-drift) * var(--emb-wb))
                    + var(--emb-live) * var(--fx-emb-edge-flare) * var(--emb-fl), 1);
  animation: emb-wave-a var(--fx-emb-edge-breathe-rate) ease-in-out infinite,
             emb-wave-b var(--fx-emb-edge-drift-rate) ease-in-out infinite,
             emb-flare var(--fx-emb-edge-flare-rate) linear infinite;
}
.emb-edge > .emb-glow__blur {
  filter: blur(var(--fx-emb-edge-radius))
          drop-shadow(0 0 var(--fx-emb-edge-haze) color-mix(in srgb, var(--fx-emb-edge-color) calc(var(--fx-emb-edge-haze-amt) * 100%), transparent));
}
.emb-edge > .emb-glow__blur::before { background-color: var(--fx-emb-edge-color); -webkit-mask-image: var(--fx-emb-edge-src); mask-image: var(--fx-emb-edge-src); }

.emb-shine2 {
  display: var(--fx-emb-shine2-on);
  mix-blend-mode: var(--fx-emb-shine2-blend);
  opacity: calc(var(--fx-emb-shine2) * var(--emb-live-shim) * var(--emb-r2-str) * var(--emb-r2-skip));
  --_s2-t: min(1, var(--emb-p2) / (var(--fx-emb-shine2-travel) * var(--emb-r2-travel)));
  --_s2-e: calc(var(--_s2-t) + var(--fx-emb-shine2-ease) * (var(--_s2-t) * var(--_s2-t) * (3 - 2 * var(--_s2-t)) - var(--_s2-t)));
  -webkit-mask-image: linear-gradient(var(--_emb-s2-angle), transparent calc(50% - var(--_emb-s2-width)), #000 50%, transparent calc(50% + var(--_emb-s2-width))),
                      radial-gradient(closest-side, #000 var(--fx-emb-fx-feather), transparent 100%);
          mask-image: linear-gradient(var(--_emb-s2-angle), transparent calc(50% - var(--_emb-s2-width)), #000 50%, transparent calc(50% + var(--_emb-s2-width))),
                      radial-gradient(closest-side, #000 var(--fx-emb-fx-feather), transparent 100%);
  -webkit-mask-size: 280% 280%, 100% 100%; mask-size: 280% 280%, 100% 100%;
  -webkit-mask-repeat: no-repeat; mask-repeat: no-repeat;
  -webkit-mask-position: calc(var(--fx-emb-shine2-from) + (var(--fx-emb-shine2-to) - var(--fx-emb-shine2-from)) * var(--_s2-e)) 0, 0 0;
          mask-position: calc(var(--fx-emb-shine2-from) + (var(--fx-emb-shine2-to) - var(--fx-emb-shine2-from)) * var(--_s2-e)) 0, 0 0;
  -webkit-mask-composite: source-in; mask-composite: intersect;
  animation: emb-shine2 var(--fx-emb-shine2-rate) linear infinite var(--fx-emb-shine2-direction);
  /* Phase: logo-intro.js shimStart() restarts this at --fx-emb-shine2-cue of its cycle (2026-09-27). */
  animation-delay: calc(-1 * var(--fx-emb-shine2-cue, 0.13) * var(--fx-emb-shine2-rate));
}

/* Irregular on purpose: uneven keyframe spacing and uneven heights, so a wave has no single
   recognisable beat, and two of them at unrelated periods never line up the same way twice. */
@keyframes emb-wave-a { 0% { --emb-wa: 0; } 14% { --emb-wa: .55; } 29% { --emb-wa: .2; } 47% { --emb-wa: 1; }
                        61% { --emb-wa: .4; } 78% { --emb-wa: .85; } 100% { --emb-wa: 0; } }
@keyframes emb-wave-b { 0% { --emb-wb: .3; } 23% { --emb-wb: 1; } 38% { --emb-wb: .1; } 55% { --emb-wb: .7; }
                        72% { --emb-wb: 0; } 88% { --emb-wb: .6; } 100% { --emb-wb: .3; } }
@keyframes emb-flare  { 0%, 31%, 40%, 73%, 80%, 100% { --emb-fl: 0; } 32.5% { --emb-fl: 1; } 36% { --emb-fl: .15; }
                        74% { --emb-fl: .7; } 75.5% { --emb-fl: 0; } 77% { --emb-fl: .45; } }
@keyframes emb-shim-wob  { 0% { --fx-emb-shim-w: 0; } 21% { --fx-emb-shim-w: .8; } 40% { --fx-emb-shim-w: -.35; }
                           58% { --fx-emb-shim-w: .5; } 79% { --fx-emb-shim-w: -.9; } 100% { --fx-emb-shim-w: 0; } }
@keyframes emb-shim-flux { 0% { --fx-emb-shim-f: 0; } 17% { --fx-emb-shim-f: .9; } 33% { --fx-emb-shim-f: .3; }
                           52% { --fx-emb-shim-f: 1; } 70% { --fx-emb-shim-f: .15; } 86% { --fx-emb-shim-f: .7; } 100% { --fx-emb-shim-f: 0; } }
@keyframes emb-shine2 { from { --emb-p2: 0; } to { --emb-p2: 1; } }
/* ▶ THE TOUCH GLOWS' DRIFT (see the @media (pointer: coarse) block at the foot of this file). The
   phone's glows are pre-blurred images rather than live filters, so nothing about them changes on
   its own — without this they are a decal. Each keyframe restates `scale` in terms of the layer's
   own spread variable, because animating `scale` alone would override the tuned value rather than
   modulating it. Uneven keyframe spacing, and periods that share no factor with the opacity waves,
   so the pattern does not repeat visibly. */
@keyframes emb-touch-driftA {
  0%   { translate: 0 0;        scale: var(--fx-emb-glow-a-spread); }
  27%  { translate: 1.5px -2px; scale: calc(var(--fx-emb-glow-a-spread) * 1.035); }
  58%  { translate: -2px 1px;   scale: calc(var(--fx-emb-glow-a-spread) * 0.985); }
  81%  { translate: 1px 1.5px;  scale: calc(var(--fx-emb-glow-a-spread) * 1.02); }
  100% { translate: 0 0;        scale: var(--fx-emb-glow-a-spread); }
}
@keyframes emb-touch-driftB {
  0%   { translate: 0 0;        scale: var(--fx-emb-glow-b-spread); }
  19%  { translate: -3px 2px;   scale: calc(var(--fx-emb-glow-b-spread) * 1.06); }
  44%  { translate: 2px 3px;    scale: calc(var(--fx-emb-glow-b-spread) * 0.94); }
  72%  { translate: 3px -2px;   scale: calc(var(--fx-emb-glow-b-spread) * 1.04); }
  100% { translate: 0 0;        scale: var(--fx-emb-glow-b-spread); }
}
/* The glow's slow breath. Opacity on a pseudo-element with no children: no side effects. */
.emblem.is3d.landed::before {
  animation: emb-pulse var(--fx-emb-pulse-rate) ease-in-out infinite;
}
@keyframes emb-pulse {
  0%, 100% { opacity: 1; }
  50%      { opacity: calc(1 - var(--fx-emb-pulse) * var(--emb-live)); }
}

@media (prefers-reduced-motion: reduce) {
  .intro-shimmer::after { animation: none; }
  .emblem.landed .intro-mark, .emblem.landed .emb-ghost,
  .emblem.is3d.landed::before, .emb-fx > * { animation: none; }
  [data-intro-hold] { opacity: 1 !important; }
}

/* The phone lever: none = no emblem effect layers on touch screens. */
@media (pointer: coarse) {
  .emblem.landed .emb-fx, .emblem.landed .emb-ghost { display: var(--fx-emb-fx-touch); }
  /* ▶ NO EMBLEM EFFECTS ON TOUCH SCREENS FOR NOW, 2026-09-16. Uriah: *"can we fallback to no effects
     for mobile for now so that we can see whether it's any of the effects causing it and add them
     back one by one"*. Turning off only the glows did not stop the dark ring or the shrinking on his
     iPhone 8 (iOS 16.7), so every effect is off and the phone gets the plain resting mark.
     ONE LINE PER EFFECT: delete a line to bring that effect back on phones, then test on the device. */
  /* ▶ ONLY THE BLURRED LAYERS ARE STILL OFF ON TOUCH, 2026-09-16 (third pass). Back on, in order of
     how they were added and tested on the device: the flicker and jitter, the colour split and
     glitch tears, then both shines and the bloom pulse.

     The rule that decides what is safe here is the one this lane cost a day to learn: **a layer must
     be correct with `mix-blend-mode: normal`; the blend may only improve it.** Everything now enabled
     paints either the transparent copy of the frame or a solid colour cut to its shape, so none of
     them depends on a blend iOS 16 may decline to honour, and none can reveal the frame's black.

     What is left off is the three BLURRED contour glows (`.emb-glow--a`, `.emb-glow--b`,
     `.emb-edge`). They are the layers the dark disc came from, and a blurred layer whose child is
     masked is the one construction this phone has never been seen to render correctly. Turn them on
     one at a time, with a device check between each — and note that glow A's promoted blend is
     `difference`, the most exotic mode in the file, so if it misbehaves try `screen` on touch first
     rather than abandoning the layer. */
  /* ▶ THE OUTLINE GLOW, 2026-09-16 — the last effect to come back, on the same pre-blurred mask
     mechanism as the other two. It is built from the Sobel EDGE map rather than the highlight map,
     so light comes off every contour the mark has rather than only off the bright chrome: the two
     read differently and layering them is what stops the glow looking like a sticker.
     Weakest of the three deliberately — it exists to define the silhouette, not to light the room. */
  .emblem.landed .emb-edge > .emb-glow__blur { filter: none; }
  .emblem.landed .emb-edge > .emb-glow__blur::before {
    -webkit-mask-image: var(--emb-src-blur-edge);
            mask-image: var(--emb-src-blur-edge);
  }
  .emblem.landed .emb-edge {
    --fx-emb-edge: 0.18;
    --fx-emb-edge-color: #cdb8ff;
    --fx-emb-edge-x: 0px;            /* promoted value is 32px — a third of a phone emblem */
    --fx-emb-edge-y: 0px;
    --fx-emb-edge-spread: 1.12;
    animation: emb-wave-a var(--fx-emb-edge-breathe-rate) ease-in-out infinite,
               emb-wave-b var(--fx-emb-edge-drift-rate) ease-in-out infinite,
               emb-flare var(--fx-emb-edge-flare-rate) linear infinite,
               emb-touch-driftA 17.3s ease-in-out infinite;
  }

  /* ▶ GLOW B, THE WIDE HALO, ON TOUCH — 2026-09-16 (fifth pass). This is the layer whose box IS the
     dark disc that started all of this: radius 34.5px at spread 1.505, which on a 123px emblem
     reaches about three quarters of the screen. It is on now because glow A proved this phone can
     blur a masked layer; the disc was never the blur, it was the opaque frame underneath it.

     Tuned to the phone rather than inherited, for the reason glow A taught: its promoted colour is
     #e9e2ff at strength 1.53 — near-white, clamped to full opacity — and near-white screened over a
     violet mark is exactly the grey wash Uriah reported. So on touch it is a deeper violet at a
     third of the strength, and the radius comes down with the mark it surrounds.

     Desktop is untouched and keeps the promoted values. */
  /* ▶▶ THE GLOWS ARE REBUILT FOR TOUCH, BECAUSE THIS PHONE CANNOT BLUR THESE LAYERS AT ALL.
     2026-09-16, settled by dev/phone-fx-test/glow.html on the device. The tiles were unambiguous:
        · glow B with `filter: blur(30px)`      → NOTHING, pixel-identical to the control
        · the same layer with the blur removed  → sharp outlines, clearly painted
        · the same layer with no shape mask     → a full block of violet
        · SVG `feGaussianBlur` instead of CSS   → NOTHING
        · a radial-gradient halo, no filter     → a large, obvious halo
        · drop-shadow on the frame image        → a soft rim, clearly there
     So the layers were painting the whole time and their SOFTNESS was being discarded — which is why
     every strength increase made the wash worse and the glow no clearer, and why Uriah's screenshots
     showed colour his eyes never saw: a capture re-renders from the layer tree, the screen does not.

     ▶ THE RULE THIS ADDS TO THE ONE ABOUT BLENDS: on this device, **a blurred layer is not a
     reliable way to make light — use paint that is already soft.** A gradient cannot be optimised
     away because there is nothing to optimise: it is just pixels. Keep both replacements off the
     desktop, which renders the real thing correctly.

     ▶ FIRST ATTEMPT, KEPT ONLY AS THE REASON FOR THE SECOND: a drop-shadow rim and a radial
     gradient. Both worked in the sense of being visible, and neither was right.

     ▶ SECOND ATTEMPT, AND THE ONE THAT SHIPS. The first replacements were a drop-shadow rim and a
     radial gradient. Uriah: *"the overlay one just looks like another layer, not a blur, and it's a
     bit off centre… the radial one doesn't look bad, just basic"* — both fair. A drop-shadow is a
     hard copy of the shape offset behind itself, and a gradient is a circle; neither is a contour
     glow. So the blur now happens OFFLINE: `rest-blur-a/` and `rest-blur-b/` are the same highlight
     map the CSS already masks with, Gaussian-blurred at build time, at 4.5% and 10.3% of image width
     — the desktop's own 15px and 34.5px expressed as a share, so both screens get the same look
     rather than the same pixel count. The phone then paints a solid colour through a soft-edged
     mask: identical construction to the desktop, minus the step this device discards.

     The colour stays tunable (only the alpha is baked), so the switchboard still governs it. */
  .emblem.landed .emb-glow--a > .emb-glow__blur,
  .emblem.landed .emb-glow--b > .emb-glow__blur { filter: none; }
  .emblem.landed .emb-glow--a > .emb-glow__blur::before {
    -webkit-mask-image: var(--emb-src-blur-a);
            mask-image: var(--emb-src-blur-a);
  }
  .emblem.landed .emb-glow--b > .emb-glow__blur::before {
    -webkit-mask-image: var(--emb-src-blur-b);
            mask-image: var(--emb-src-blur-b);
  }
  /* ▶ AND CENTRED. The promoted offsets (x 4.5px, y −16.5px) were tuned against a 334px desktop
     emblem; on a 123px phone the same pixels are nearly three times the proportional shift, which is
     the "bit off centre" Uriah saw. Zero here — the glow sits on the mark it belongs to. */
  .emblem.landed .emb-glow--a { --fx-emb-glow-a-x: 0px; --fx-emb-glow-a-y: 0px; }
  .emblem.landed .emb-glow--b { --fx-emb-glow-b-x: 0px; --fx-emb-glow-b-y: 0px; }

  /* ▶ AND IT MOVES, GENTLY. Uriah: *"the radial glow could do with its parameters changing more
     dynamically so that the emblem actually looks like a shine artifact, not over the top"*. The
     halo drifts a few pixels and breathes a few percent on periods that share no factor with the
     opacity waves (13.9s and 21.1s against 5.4s and 29.1s), so the combination never lands the same
     way twice and never reads as a loop. Amounts are deliberately small: this should read as light
     settling on a surface, not as an animation. */
  .emblem.landed .emb-glow--a { animation: emb-wave-a var(--fx-emb-glow-a-breathe-rate) ease-in-out infinite,
                                           emb-wave-b var(--fx-emb-glow-a-drift-rate) ease-in-out infinite,
                                           emb-flare var(--fx-emb-glow-a-flare-rate) linear infinite,
                                           emb-touch-driftA 13.9s ease-in-out infinite; }
  .emblem.landed .emb-glow--b { animation: emb-wave-a var(--fx-emb-glow-b-breathe-rate) ease-in-out infinite,
                                           emb-wave-b var(--fx-emb-glow-b-drift-rate) ease-in-out infinite,
                                           emb-flare var(--fx-emb-glow-b-flare-rate) linear infinite,
                                           emb-touch-driftB 21.1s ease-in-out infinite; }

  /* The halo's strength and its breathing. Both waves are damped from the promoted values for a
     reason measurement found rather than taste: `--fx-emb-glow-b-drift: 1` multiplies the opacity by
     (1 − wave), and the wave reaches 1 — so the desktop layer drops to ZERO for part of every cycle.
     Sampled over 17 seconds it ran 0.00–0.54: genuinely absent as often as present, which on its own
     could read as "I can't see it". A halo that vanishes looks like a fault; a halo that breathes
     between two visible levels looks alive. */
  .emblem.landed .emb-glow--b {
    --fx-emb-glow-b: 0.24;
    --fx-emb-glow-b-color: #8f6fe8;
    --fx-emb-glow-b-spread: 1.35;
    --fx-emb-glow-b-breathe: 0.3;
    --fx-emb-glow-b-drift: 0.2;
  }

  /* ▶ GLOW A IS BACK ON FOR PHONES, WITH ITS BLEND SWAPPED, 2026-09-16 (fourth pass). It is the
     tight rim — radius 15px against glow B's 34.5px and spread 1.065 against B's 1.505 — so it is
     both the smallest blurred layer and the one whose box is nothing like the dark disc that was
     reported. That makes it the right first test of whether this phone can blur at all.

     THE BLEND IS `screen` HERE AND `difference` EVERYWHERE ELSE, DELIBERATELY. The promoted tuning
     uses `difference`, which is the most exotic mode in this file and the one least likely to be
     honoured by iOS 16's compositor — and an unhonoured blend is how every dark artefact in this
     lane happened. On a near-black page a white glow differenced and a white glow screened read
     almost identically, so the cost is nearly nothing and the risk removed is real. Desktop is
     untouched: it keeps `difference` from the tuning block.

     If the phone shows a clean rim: try glow B next (delete it from the rule above), and only then
     the outline glow. If it shows a dark ring again, the answer is that this phone cannot blur a
     masked layer, and the glows stay a desktop-only flourish — which is a finding, not a failure. */
  .emb-fx > .emb-glow--a { mix-blend-mode: screen; }
  /* ▶ AND STRONGER ON A PHONE, 2026-09-16. Uriah, after the first try: *"no black ring, but I'm not
     sure I can see anything"* — which is the good outcome plus a visibility problem, not a failure.
     Two reasons it under-reads here: `screen` over near-black is gentler than the `difference` the
     desktop uses, and the emblem is ~123px on his phone against ~260px on a desktop, so a 15px blur
     is a much larger share of a much smaller mark and spreads the same light thinner. The tuned
     0.37 becomes 0.9 on touch only; desktop keeps the promoted value. If it is now obvious, the
     phone CAN blur a masked layer and glow B is worth trying. */
  /* ▶ AND IT IS VIOLET ON TOUCH, NOT WHITE — WHICH IS WHAT THE LAST TEST ACTUALLY FOUND.
     Uriah: *"the glow's not any more visible, but there is noticeably more time where the emblem is
     grey/clear instead of the purple colour it usually is"*. That is not a missing glow, it is this
     glow working: `--fx-emb-glow-a-color` is `#ffffff`, and white screened over a violet mark
     desaturates it. Raising the strength made the WASH stronger, never the rim clearer — the layer
     was there the whole time and was reading as "the emblem lost its colour".

     Sampled from the resting frame itself: mid-tones (176,149,253) — hue 256°, and highlights a
     near-white violet. So on touch the glow takes the mark's own violet, and screening ADDS that hue
     instead of bleaching it. Desktop keeps white with `difference`, where the inversion against a
     near-black page produces the colour rather than removing it.

     If this reads as a violet bloom on the mark, blur works on this phone and glow B is worth
     trying. If it still reads as grey, the wash is winning over the rim and the answer is a smaller
     radius, not a bigger number. */
  /* Strengths for the pre-blurred masks, cut 20% from the first set that read correctly (0.45 /
     0.30 / 0.22) on Uriah's judgement: *"it could be about 20% less intense at its peak"*. The masks
     are renormalised to full range at build time, so these are the only place brightness is decided — which is the point: a number the switchboard
     can move, rather than whatever a blur happened to leave behind. */
  .emblem.landed .emb-glow--a {
    --fx-emb-glow-a: 0.36;
    --fx-emb-glow-a-color: #b195fd;
  }
}

/* ▶ THE FAILSAFE WAITS LONGER ON A HANDHELD, AND ONLY THERE, 2026-09-19.
   ▶ IT LIVES AT THE FOOT OF THE FILE DELIBERATELY. Both base rules use the `animation`
   SHORTHAND, which resets animation-duration; this override sets the longhand. Same
   specificity, so the cascade is decided purely by order, and the two base rules sit ~85
   lines apart. Placed between them it won one and lost the other - verified by reading the
   computed duration on a live page, which is the only way that mistake shows up.
   `SPIN_SETTLE` is 1,350 ms on a coarse pointer and 350 ms elsewhere, so the worst-case intro is
   ~11.15 s on a phone and ~10.15 s on a desktop. This net must stay LAST, after the watchdog, or
   it starts revealing the copy while a healthy intro is still flying — so it moves out with the
   thing it is protecting, and only on the devices where that thing got longer.

   ▶ IT IS THE ONLY NUMBER IN THE LADDER THAT IS NOT DERIVED. The watchdog is computed from
   SHEET_BUDGET + SPIN_SETTLE + MIN_SPIN + cfg.dur, so it follows any of them automatically. This
   one is a literal in a stylesheet, which is exactly how it came to be documented against "land
   at ~4.9s" long after the landing had moved to 6.65s. If any of those four change again, check
   this. */
@media (pointer: coarse) {
  html.intro-armed [data-intro-hold]:not(.intro-in) { animation-duration: 14s; }
  html.intro-armed .shell > *:not(main):not(.reg),
  html.intro-armed main > *:not(.hero):not([data-intro-hold]),
  html.intro-armed .hero > *:not(.emblem):not(.emblem-host):not([data-intro-hold]) {
    animation-duration: 14s;
  }
}
/* ▶ STRUCTURE REPAIRED 2026-09-28. From 2026-09-20 this file carried stray closing braces (three, by the end)
   and this block had lost its own. The browser dropped the rule after each stray brace (the reduced-motion
   stagger and the "nothing but the emblem" hide above) and applied everything from here to the end of the
   file to touch screens only. Found by reading document.styleSheets, not the source. */

/* ▶ THE TRANSFORM SPRITE (?smooth=xform only), 2026-09-19.
   An inner element sized to the whole sheet grid, slid by whole cells with `transform` so the
   spin runs on the compositor instead of repainting a 753-device-px box sixty times a second.
   logo-intro.js only builds this element when the query parameter asks for it, so these rules
   match nothing on a normal visit.

   `backface-visibility` and `translate3d` keep it on its own layer without `will-change`, which
   is deliberately avoided: a will-change layer TORN DOWN inside a clipping ancestor is the pair
   that stopped iOS repainting the hero on 2026-09-17. This layer is created once and never
   removed while the spin runs, which is the difference — but it is the same family, which is why
   the whole mode is opt-in. */
.intro-mark > .intro-sprite {
  position: absolute;
  top: 0;
  left: 0;
  background-repeat: no-repeat;
  transform-origin: 0 0;
  backface-visibility: hidden;
  will-change: auto;
}
