/* RAD intro overlay — PROTOTYPE, draft-14-intro only. Not part of the mainline build.

   ============================ ROUND 14 ============================
   A ROOT-CAUSE round. Rounds 11-13 tuned numbers; the client saw the same three
   problems each time, so this round changes the TECHNIQUES instead.

   (14a) TEXT PACING. The travelling mask gradient is GONE from this file. It could not
   be made to read as "slower" by lengthening it, because it never read as a SPEED at
   all: the feather was 70% of the block on each front — 232px of ramp on a 331px block —
   so at any instant nearly every letter was part-transparent and the whole lockup read
   as a blur resolving. Blur has no perceptible rate; only discrete events do. Round 12
   took the authored sweep 900 -> 1300ms and the client still said "the text moves fast",
   which is the signature of adding duration to an invisible alpha tail (the easing is
   easeOut, so legibility is front-loaded and the extra time lands after the words are
   already readable). The reference the client keeps pointing at, minaleandmann.com,
   reads as PACED because its wordmark is twelve separate SVG shapes fading in one after
   another — a rhythm you can count. So this round splits both lines into per-character
   spans and staggers them; see `.rad-intro__ch` below and the timeline in intro.js.
   Everything the mask needed — mask-image / -size / -position / -repeat / -origin /
   -clip, mask-mode, the two mask-position animations and the composite-order note that
   went with them — has been deleted from both files.

   (14b) FIREFOX. Round 13's FPS guard has been removed. It sampled the frame rate at
   intro start and, on a GPU-composited Gecko, that probe PASSES — so the blur stayed on
   and the compositor then dropped frames exactly where three things composite at once
   (scrim fade + logo fade + a full-screen backdrop-filter). The guard was measuring the
   wrong moment. The LIVE blur is now dropped for Gecko unconditionally; see
   `.rad-intro--flat` below. (Round 17 keeps that decision — no backdrop-filter on
   Gecko, ever — and gets the look back a different way, with a pre-blurred image.)

   (14c) THE ENDING. Nothing switches state at the end any more — see the note on the
   hiding rule at the foot of this file.

   ============================ ROUND 15 ============================
   Two client notes, both one-property changes. EXIT TOGETHER: the reveal keeps its one
   continuous stagger across both lines, unchanged; only the exit's stagger now resets at
   the start of line 2, so the two lines close on the hinge together instead of one
   queueing after the other (mechanics live in intro.js — see the header note there).
   SCRIM DENSITY: --intro-scrim below moves .90 -> .96, matched exactly in index.html's
   pre-paint <style> as it must be — see the note on --intro-scrim.

   ============================ ROUND 16 ============================
   Two client notes, both scoped to a single browser and both one-property changes.
   FIREFOX SCRIM DENSITY: Round 14b drops Gecko's backdrop-filter outright (see
   .rad-intro--flat below), which also means the sliver of hero photo the scrim
   doesn't cover is no longer smeared — the same .96 alpha then reads visibly more
   transparent on Firefox than on Chrome, which still has its blur. --intro-scrim is
   overridden to .99 on .rad-intro--flat, Gecko-only; Chrome is untouched at .96 with
   blur(6px).  [SUPERSEDED BY ROUND 17 — the .99 override is gone; Gecko is back on
   the same .96 as Chrome now that it has a blurred backdrop again.]
   LANDING FADE: intro.js takes T_HANDOFF 450 -> 700 this round, to make a
   single dropped Gecko frame a smaller fraction of the landing dissolve's opacity
   travel — a JS-only change, noted here for the record; see intro.js for the T_END
   margin check it required.

   ============================ ROUND 17 ============================
   One client note: Firefox should LOOK like Chrome — soft blurred backdrop — and keep
   the smooth animation Round 14b bought by throwing that look away.
   Rounds 14b and 16 both assumed the choice was "live blur or no blur". It is not,
   because the backdrop is a STILL PHOTOGRAPH for the whole intro: `backdrop-filter`
   was re-sampling the viewport 60 times a second to produce the same image every time.
   So the blur moves offline. _build_images.py bakes a Gaussian-blurred copy of the
   homepage hero (1600px, sigma 5.0 — the derivation, including why 5.0 in source pixels
   is 6.0 CSS pixels on screen, is in that file), and .rad-intro--flat draws it as a
   full-bleed `cover` layer under the scrim instead of filtering anything. Gecko's
   --intro-scrim override goes away with it: both browsers are .96 again, which also
   makes the pre-paint scrim in index.html's <head> exactly right on both for the first
   time (it has always been .96 and Firefox was stepping to .99 at overlay insertion).
   Chrome is byte-for-byte unchanged — it never matches .rad-intro--flat.
   Nothing about the timeline, the stagger, the flight or the teardown moves this round.

   ============================ ROUND 18 ============================
   One client note, and it is the last thing the two-browser split was still costing:
   ON CHROME THE HERO IS SHARP FOR A SPLIT SECOND, THEN BLURS.
   It was doing exactly that, and the reason was structural rather than a timing bug.
   Chrome's blur came from a LIVE `backdrop-filter` that only existed once intro.js had
   built the overlay — and intro.js is deferred, so it cannot run before the document has
   parsed. The gap was covered by the pre-paint scrim in the <head>, which painted a flat
   colour and NO blur. So the first frames were a 96% scrim over a SHARP photograph, and
   the blur appeared underneath it a beat later. Firefox never showed this, because since
   Round 17 its blur has been a pre-baked image — nothing to arrive late.
   ROUND 18 DELETES THE SPLIT. There is no `backdrop-filter` in this file any more, on
   any browser, and no `.rad-intro--flat` class: the baked blur is simply what the intro
   uses. The same image is also painted by the pre-paint rule in the <head>
   (_build_site.py's INTRO_HEAD), under the same .96 scrim, `center/cover` — so the
   FIRST frame the browser paints is already blurred and the overlay's arrival changes
   nothing visible. A <link rel=preload as=image> in the head gets the 54KB file there in
   time for that first frame.
   What Chrome loses by giving up the live filter: the filter blurred the backdrop AS
   RENDERED, so it also smeared `.hero::after`'s vertical scrim wash and the hero
   wordmark sitting on top of the photograph; the baked image carries neither (it is the
   photograph alone, greyscaled and blurred). Both sit under a 96% scrim, i.e. at a
   twenty-fifth of the frame's light, which is the same argument that made the baked
   radius acceptable on Firefox in Round 17 — and unlike Round 17's, this one was
   checked against the live filter side by side rather than argued.
   What Chrome gains: the blur is there from frame one, and it is now the same code and
   the same pixels the other browser has been shipping.
   Nothing about the timeline, the stagger, the flight or the teardown moves this round.

   ============================ ROUND 19 ============================
   The client compared them and chose: Chrome's LIVE `backdrop-filter: blur(6px)` — the
   look Rounds 8-17 shipped — is "more refined" than Round 18's baked image, and he wants
   it back. He also does not want Round 18's fix undone: the blur must still be there on
   the FIRST painted frame.
   Round 18 had assumed those two were in tension. They are not, and the assumption is
   the only thing wrong with it. Its reasoning ran: a live filter cannot exist before the
   script that builds the element it lives on, therefore the pre-paint layer cannot
   reproduce it, therefore both layers must use the image. The first step is true; the
   second is false. `backdrop-filter` needs an ELEMENT, not a script — and the pre-paint
   layer already has one, `html.rad-intro-armed::before`, generated by a <style> block in
   the head with nothing deferred about it. Putting the same filter on that pseudo-element
   makes the first frame the browser paints already blurred, by the live filter itself.
   So Round 19 restores the split Round 18 deleted, on the axis it should have been on
   all along — not "before the script vs after" but "which ENGINE":
     CHROME (and any non-Gecko)  live `backdrop-filter: blur(6px)`, in BOTH layers: the
                                 head's pre-paint ::before and this file's `.rad-intro`.
                                 One filter, two hosts, so the handover is invisible.
                                 No baked image is fetched or painted at all.
     FIREFOX (Gecko)             the Round-17/18 baked image, in both layers, exactly as
                                 shipped. Round 14b's measurement is why, and it has not
                                 expired: a live filter costs Gecko ~41ms/frame against
                                 ~7ms, because it re-samples the backdrop every frame to
                                 produce an image that never changes. The table below is
                                 unchanged and is still the whole argument.
   `.rad-intro--flat` is therefore BACK, and back to meaning what it meant in Round 17:
   "this engine cannot afford the live filter". intro.js's isGecko() returns with it, and
   _build_site.py's arming script does the same test one layer earlier, before any
   stylesheet has been parsed, so the head block can branch too (see INTRO_HEAD there).
   WHAT THE TWO ENGINES NOW SEE, and how close that is: measured this round, same instant
   mid-intro, 1920x1080 — see the Round-19 note in _build_images.py for the numbers and
   for the bake's own improvement (the `.hero::after` wash is now composited into the
   baked file before blurring, which was the largest remaining difference).
   Nothing about the timeline, the stagger, the flight or the teardown moves this round.

   Round 8 context, still current for the LOGO: it arrives on the dark translucent scrim
   with opacity plus a whisker of scale — no blur and no filter of any kind (blur read
   badly on a flat red disc). It holds, then flies solo to the header mark while the
   wordmark stays exactly where it sits and closes itself down in place. The client
   overturned Round 6's "text travels the logo's own vector" idea (it read as the logo
   leaving first and the words catching up), so the two share only the INSTANT they
   start, never a vector.
   Everything here is scoped so it can only ever affect the homepage. */

.rad-intro {
  /* always the dark scrim, regardless of site theme — client felt the light
     variant read too bright. The intro is now a self-contained palette (see
     .rad-intro__word / .rad-intro__sub below) rather than a theme-aware one.
     This colour is DUPLICATED, deliberately, in the pre-paint scrim inlined in
     index.html's <head> (Round 14d). The two must stay identical or the handover from
     the pre-scrim to this overlay would show a seam. */
  --intro-scrim: rgba(38, 37, 33, .96);
  /* phones only: intro.js measures the real stack height and writes the lift that
     centres logo + wordmark together on the viewport. 0 everywhere else. */
  --intro-lift: 0px;
  position: fixed;
  inset: 0;
  z-index: 200;
  /* Transparent HERE, and overridden immediately below on every engine that takes the
     live filter. Round 19 left this transparent for both engines and put the scrim on
     ::after for both, reasoning that a `backdrop-filter` samples what is painted BEHIND
     an element, so a scrim that is a child rather than this element's own background
     cannot change what gets blurred. That reasoning is correct and Round 20 measured it
     to be correct — the two arrangements render the same frame to the pixel. It is still
     not what this file should say. See the ROUND 20 note on the rule below. */
  background: transparent;
  pointer-events: none;
  will-change: opacity;
}

/* ---- Round 19: the LIVE blur, restored — Chrome and every non-Gecko engine ----
   This is the declaration Rounds 8-17 shipped and the client picked back out of the
   comparison: `blur(6px)`, over the .96 scrim on ::after. Nothing about it is new; it is
   quoted back verbatim from the Round-17 file, radius and all, because "the original" is
   exactly what was asked for.
   THE `:not()` IS THE ENGINE SPLIT. intro.js adds `.rad-intro--flat` to the overlay when
   it detects Gecko and only then, so this rule is simply "every engine that can afford
   it". Written as a negation, deliberately, so the LIVE filter is the default and the
   baked image is the exception that has to be asked for — the reverse of Round 17, and
   the right way round now that the live filter is the approved look rather than the
   expensive one.
   -webkit- twin: Safari has never unprefixed this property. Both are listed with the
   prefixed one FIRST, so a browser that understands both takes the standard.
   THE SAME PAIR IS ALSO IN THE HEAD. _build_site.py's INTRO_HEAD puts these two
   declarations on `html.rad-intro-armed::before`, which exists before any script runs —
   that is what makes the FIRST painted frame blurred instead of showing a sharp hero for
   a beat (the Round-18 complaint, fixed here without giving up the live filter). If the
   radius here ever changes, change it there too; they are the same blur seen through two
   different hosts and a mismatch would step visibly at overlay insertion.

   ============================ ROUND 20 ============================
   Client note: Chrome's blur "doesn't quite look as clean" as the original. The standing
   suspicion was compounding — two live `backdrop-filter`s in the page at once (this one
   and the head's pre-paint ::before), the second blurring a backdrop the first had
   already blurred.
   MEASURED, AGAINST THE SHIPPED draft-15 ITSELF (served on a second port, both builds
   driven through the same CDP harness, every animation paused and seeked to the same
   currentTime so the two frames are the same instant of the same timeline —
   _qa/r20-introcmp.mjs / r20-introcmp2.mjs / r20-diff.py):

     1920x1080, t=800/1500/2500, software raster   mean |diff| 0.000   max 0
     1920x1080, t=1500, real GPU (ANGLE D3D11)     mean |diff| 0.000   max 0
     1920x1080, t=1500, devicePixelRatio 2         mean |diff| 0.000   max 0
     2560x1440, t=1500                             mean |diff| 0.267   max 193
        ... of which the BACKDROP is                mean |diff| 0.000   max 0
        (all 0.267 is type: the intro wordmark 85.6%, the hero wordmark 13.5%, the nav
         0.8%. Nothing to do with this file — site.css gained a fluid root font-size
         after draft-15, `clamp(16px, .25vw + .7rem, 18.5px)`, which is exactly 16px at
         1920 and 17.6px at 2560, so every rem-sized glyph moves on the wider screen and
         none of them move on the client's own monitor.)

   So the compounding never happens, and the reason it never happens was also measured
   rather than argued: a rAF probe recorded <html>'s class list and the overlay's presence
   on every animation frame of the opening, across both builds, and NO FRAME EVER CARRIED
   BOTH (0 of 189). intro.js's disarm() runs in the same task as the insertion, so the two
   filters cannot meet. Forcing them to meet, for the record, costs mean |diff| 3.124 with
   44% of the frame moving by more than 2 levels — i.e. if it ever did happen it would not
   be subtle, and it is not what the client is looking at.
   WHAT ROUND 20 CHANGES, THEN, AND WHY IT CHANGES ANYTHING AT ALL. The rendering was
   already right; the STRUCTURE was not. Round 19 left Chrome on a stack draft-15 never
   had — scrim on a child ::after, logo and wordmark lifted to z-index 1 to clear it — and
   defended it on the grounds that it renders the same. It does render the same, today, in
   this Chrome, on this GPU. That is a measurement, not a guarantee, and it is a thin
   thing to be standing on when the brief is "identical to the build he approved".
   So the two engines are split here as well: the non-Gecko path is now draft-15's rule
   VERBATIM — the scrim is this element's own background and the filter sits on the same
   element, exactly as in the file the client signed off — and everything Round 17 built
   for Gecko (the ::after scrim, the baked image layer, the z-index pair) is scoped to
   `.rad-intro--flat`, which is the only engine that ever needed it. Chrome's overlay now
   has no ::after and no z-index rules at all. Re-measured after the change: still 0.000.
   Nothing about the timeline, the stagger, the flight or the teardown moves this round. */
.rad-intro:not(.rad-intro--flat) {
  background: var(--intro-scrim);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
}

/* ---- Round 14b: no LIVE blur on Gecko, unconditionally ----
   ---- Round 18: no live blur ANYWHERE. The class this rule used to carry
   (`.rad-intro--flat`, added by intro.js only when it detected Gecko) is gone, and its
   three declarations are folded into `.rad-intro`, `.rad-intro::after` and the logo/text
   z-index rules below — because they are now what every browser gets. The reasoning
   below is kept in full: it is why the filter can never come back, on either engine.
   intro.js used to add that class when it detected Gecko, at overlay-build time, with no
   measurement of any kind. It replaced Round 13's frame-rate guard, which asked "is this
   machine painting smoothly right now?" at intro START — the one moment when a
   GPU-composited Firefox answers yes. The blur is cheap while nothing else is
   compositing and ruinous at the ending, where the scrim fade, the logo fade and a
   full-viewport backdrop-filter all land on the same frames; by then the guard had long
   since decided to keep it. Measuring later would only move the problem, so this round
   stops measuring.
   The Round-13 numbers that justify dropping it are unchanged and still the reason this
   is an acceptable trade (Firefox 153.0.3, WebDriver BiDi, 1600x900, bare
   requestAnimationFrame-timestamp probe reading no DOM at all):
       intro as shipped, blur on            median 42ms/frame, max 89ms   (~23fps)
       identical page, blur off             median 17ms/frame, max 18ms   (~59fps)
       after the overlay leaves the DOM, either way            17ms       (~59fps)
   and a variant sweep showed the cost is the PRESENCE of the filter, not its radius —
   blur(1px) 43ms, blur(3px) 51ms, blur(6px) 42ms, will-change removed 42ms, its own
   layer 42ms, none 17ms. There is no cheaper LIVE blur to retreat to, only none — which
   is exactly why Round 17 stopped looking for one and precomputed the image instead.
   WHAT FIREFOX LOSES, as of Round 17: nothing visual. It draws a baked Gaussian of the
   same hero, under the same .96 scrim, at a radius chosen to match blur(6px) on screen
   (see the Round 17 rule below and the derivation in _build_images.py). The scrim is
   rgba(38,37,33,.96) (Round 15 raised it from .90 — see --intro-scrim above), so
   whatever the blur acts on is in any case only the ONE TWENTY-FIFTH of the photograph
   that shows through — under half a percent of the frame's total light, down from about
   1% at .90. That figure is what makes the remaining approximation in the baked radius
   (4-8 CSS px across a 1280-2560 viewport range, against Chrome's exact 6) untestable by
   eye. Chrome is untouched through all of this and keeps the approved look exactly. */

/* ---- Round 17: Firefox gets the LOOK back, without buying the frames ----
   Rounds 14b and 16 were both retreats. 14b dropped Gecko's backdrop-filter because it
   cost ~25ms a frame; 16 then pushed Gecko's scrim to .99 to hide the fact that the
   sliver of photo showing through was now sharp instead of smeared. The client's note
   this round is simply that Firefox should LOOK like Chrome — soft blurred backdrop —
   and still animate smoothly.
   Both of those can be true at once, because of one thing neither earlier round used:
   THE BACKDROP IS A STILL PHOTOGRAPH. It is the homepage hero, it does not move, and
   the overlay sits over it unchanged for the whole 3718ms. `backdrop-filter` re-samples
   it every frame to produce an image that is identical every frame. So the blur is
   computed ONCE, offline, by _build_images.py — see the radius derivation in that file —
   and shipped as a 1600px JPEG. Here it is simply drawn.
   ---- HOW IT IS DRAWN, and why not the obvious way ----
   The obvious way is `background-image: url(blur) ; background-size: cover` on the
   overlay itself. It looks right and it is SLOW — measured, on this machine, Firefox
   153.0.3 headless, 1600x900, bare rAF-timestamp probe (_qa/ff17-imgsize.mjs):

       Round-16 flat, no image at all         median  7ms/frame     2 of 438 frames >16.7ms
       live backdrop-filter: blur(6px)        median 40ms/frame     (the Round-14b problem)
       blur image, background-size: cover     median 17ms/frame   ~120 of 198 frames >16.7ms
       blur image, natural-size layer + transform
                                              median  7ms/frame     2 of 438 frames >16.7ms

   The middle row is better than a live filter and still misses more than half its
   frames. The cost is NOT the image: 400px, 800px and 1600px sources measured
   identically (17ms), progressive and baseline JPEG measured identically, and a plain
   CSS linear-gradient with no image at all measured WORSE (34ms). What is expensive is
   a SCALED BACKGROUND on a full-viewport box that keeps being invalidated — the 38
   character spans carry no `will-change` by design (see .rad-intro__ch), so their
   opacity animations are not composited and each one dirties this element; every such
   repaint re-resolves the scaled background. Promoting the spans does not help either,
   which is what ruled the repaint-surface theory out (_qa/ff17-variants.mjs: 17-18ms
   for every promotion combination tried).
   So the image is NOT scaled by the background code at all. It lives on
   `.rad-intro__blur` below — its own element, sized in CSS to the file's exact natural
   size, `background-size: 100% 100%` so there is no scaling to resolve, promoted with
   `will-change: transform`, and then fitted to the viewport by a STATIC transform that
   intro.js computes once. Gecko rasterizes that layer a single time and composites it
   as a scaled quad from then on. Nothing re-samples, and the frame cost is the flat
   build's, to the millisecond.

   Round 18 kept every word of that and applied it to Chrome as well, on the grounds that
   Chrome's live filter could not exist before the deferred script that hosted it.
   ROUND 19 PUTS CHROME BACK ON THE LIVE FILTER (the client compared the two and chose
   it), because the premise was wrong: the head's pre-paint pseudo-element is an element
   too, and a filter on it is live from the first frame. Everything above still stands,
   word for word, for GECKO — which is the engine it was measured on and the only engine
   that now uses it. The three declarations are scoped back to `.rad-intro--flat`:
   the baked layer below, its z-index-0 slot under the scrim, and nothing else. The scrim
   on ::after and the logo/wordmark z-index stay unscoped, because they are harmless on
   both engines and one structure is easier to keep true than two.
   ROUND 20 SCOPES THEM. "Harmless on both engines" was true and was measured to be true
   (0.000 whole-frame difference against draft-15), and it is still the wrong trade: the
   brief is that Chrome must be IDENTICAL to the build the client approved, and identical
   is a stronger claim than indistinguishable-on-this-machine. So the ::after scrim, the
   baked layer and the z-index pair are all `.rad-intro--flat` now — Gecko's structure,
   for Gecko only. A non-Gecko overlay has no ::after, no child layers but the logo and
   the wordmark, and no z-index rules: draft-15's element, exactly. */

/* The scrim, a layer of its own so the blur can sit under it — GECKO ONLY (Round 20;
   see above). A flat background-COLOUR on a positioned box, which is the cheap case — it
   is a scaled background-IMAGE that costs frames, per the table above.
   z-index 0 keeps it in the same painting group as the blur, where tree order decides:
   ::after is generated last, so it paints over .rad-intro__blur and under the logo and
   wordmark at z-index 1. It is `inset: 0` on the overlay, so it covers the viewport
   whatever the blur layer does — which is what makes every failure mode below safe.
   THE COLOUR IS ALSO THE PRE-PAINT LAYER'S. `html.rad-intro-armed::after` in the head
   paints rgba(38,37,33,.96) over whatever that block's ::before produced — the live
   filter on Chrome, the baked image on Gecko — and if these two colours ever drift, the
   handover from one to the other steps in density on screen. On the non-Gecko side the
   same colour arrives as `.rad-intro:not(.rad-intro--flat)`'s own background instead;
   it is still one value, `--intro-scrim`, so there is still nothing to keep in step. */
.rad-intro--flat::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  background: var(--intro-scrim);
}

/* The pre-blurred hero — GECKO ONLY. Round 18 built it on every browser; Round 19 puts
   Chrome back on the live filter, so this is once again the Round-17 arrangement: the
   element is created by intro.js only when it detects Gecko, and the selector is scoped
   to `.rad-intro--flat` as well so the two can never both paint. (They would not merely
   be redundant: this is a CHILD of the overlay, so on an engine with the live
   `backdrop-filter` it would paint ON TOP of the already-blurred backdrop and read as a
   double blur.) Chrome fetches this file not at all — the <link rel=preload> for it is
   injected by the head's arming script on Gecko only; see INTRO_HEAD in _build_site.py.
   WIDTH/HEIGHT ARE THE FILE'S NATURAL SIZE and must stay in step with _build_images.py
   (BLUR_W = 1600; the height follows from the hero's 1.4859:1). They are the reason
   `background-size: 100% 100%` involves no scaling, and intro.js reads them straight
   back off the element with offsetWidth/offsetHeight rather than duplicating them — so
   changing them here is enough, and there is no second copy to forget.
   TRANSFORM: none by default, deliberately. If intro.js never runs its fit — an
   exception, a fetch that never lands — the layer simply sits at natural size in the
   top-left corner, UNDER the ::after scrim, at 4% of the frame's light. The page is
   still fully scrimmed and still looks like the Round-16 build. There is no failure
   mode here that shows an uncovered viewport.
   RESIDUE: none. It is a child of the overlay, so it leaves the DOM when intro.js
   removes the overlay at teardown — nothing extra to hide or unhook. */
.rad-intro--flat .rad-intro__blur {
  position: absolute;
  top: 0;
  left: 0;
  width: 1600px;
  height: 1077px;
  z-index: 0;
  transform-origin: 0 0;
  background: url("../img/projects/black-rock-house/black-rock-house-01_blur.jpg")
              0 0 / 100% 100% no-repeat;
  will-change: transform;
  pointer-events: none;
}

/* The logo and the wordmark move up a painting group, so the scrim's ::after cannot
   cover them — GECKO ONLY since Round 20. Unscoped in 18 and 19, because the ::after
   scrim was on both engines then and this had to follow it. It is scoped now for the
   same reason ::after is: on a non-Gecko overlay the scrim is once again the element's
   OWN BACKGROUND, and an element's background paints below its children by definition,
   so there is nothing for the logo or the wordmark to climb over and no rule needed.
   That is precisely the arrangement draft-15 shipped and Rounds 8-17 before it.
   It changes nothing about how either element is positioned, animated or handed over. */
.rad-intro--flat .rad-intro__logo,
.rad-intro--flat .rad-intro__text {
  z-index: 1;
}

/* The logo is its own absolutely-positioned layer: the flight animation moves ONLY
   this element, so the text beneath it never travels with it.
   No filter here — the WAAPI entrance is opacity + scale and nothing more. */
.rad-intro__logo {
  position: absolute;
  left: calc(50% - 84px);
  top: calc(50% - 84px + var(--intro-lift, 0px));
  width: 168px;
  height: 168px;
  opacity: 0;                    /* the WAAPI timeline drives it from here */
  transform: scale(.98);
  transform-origin: 50% 50%;
  will-change: transform, opacity;
  -webkit-user-select: none;
  user-select: none;
}

/* Wordmark block — hangs off the logo's bottom edge, so on desktop the logo itself
   stays optically centred in the viewport (its centre is what feeds the LOGO'S OWN
   travel maths in intro.js — the text no longer shares it). The box is
   shrink-wrapped to the widest line (width:max-content + auto side margins inside
   the left:0/right:0 slot). Measured live: 331.0px wide on desktop, 253.8px on phones.
   This block NEVER moves and never transforms; since Round 14 it does not carry an
   opacity or a mask of its own either. Every visual change to the wordmark now happens
   one character at a time, on the spans below. */
.rad-intro__text {
  position: absolute;
  left: 0;
  right: 0;
  top: calc(50% + 84px + var(--intro-lift, 0px));
  width: max-content;
  max-width: 100%;
  margin-left: auto;
  margin-right: auto;
  text-align: center;
  -webkit-user-select: none;
  user-select: none;
}

/* ---- Round 14a: the per-character stagger ----
   intro.js rebuilds both lines as one span per character at build time. The strings
   themselves are unchanged; what changes is that each letter is now its own animatable
   box, so the reveal can be a countable rhythm (420ms per character, 48ms apart, left to
   right, continuous across both lines) instead of one smeared front.

   REST STATE = 0. The lines are therefore invisible before a line of JS runs, exactly as
   the mask's parked position used to guarantee, and there is no flash if scripting is
   slow. The spans that hold the WORD GAPS are built with a non-breaking space and are
   never animated; they stay at 0 for the whole intro, which is invisible either way
   because a space has no ink. Their only job is to hold the gap open so the line's
   metrics are identical to the plain text node they replaced.

   Load-bearing, not cosmetic:
     - display:inline-block is what makes opacity (and any future transform) apply to a
       run of text. It must NOT change the line's metrics: a single-character
       inline-block still takes the character's advance plus the inherited
       letter-spacing, and it aligns on the same baseline as the strut, so the box comes
       out the same width and the same height. Verified by measuring the block's bounding
       rect before and after the change: 331.000 x 75.984px became 331.172 x 75.984px —
       +0.172px of width from sub-pixel rounding accumulating over 42 spans, no change in
       height, and the block stays centred. See the note in intro.js.
     - the gap spans use U+00A0, not U+0020: a plain space at the START or END of an
       inline-block would be collapsed away by white-space processing and the words would
       close up.
     - NO will-change on these spans. There are 38 of them animating; promoting each to
       its own compositor layer would cost far more than the opacity animations save, and
       an opacity animation is composited without the hint anyway.
     - the parent has no opacity of its own, so nothing multiplies these values and the
       authored per-character curve is what is painted. */
.rad-intro__ch {
  display: inline-block;
  opacity: 0;
}

/* line 1 — the HOMEPAGE HERO WORDMARK, not the footer one: one uniform weight (630)
   across the whole line, same width axis (wdth 116), same .045em tracking — no
   two-tone split, unlike `.hero h1` / `.hero h1 .thin` in site.css (a deliberate
   difference for this overlay; this prototype does not share that stylesheet's
   rules). No opacity of its own — since Round 14 the entrance and the exit both belong
   to the individual characters. The REVEAL still continues line 1's stagger straight
   into line 2 (unchanged since Round 14), so the two lines read as ONE left-to-right
   pass on the way in. Since Round 15 the EXIT does not: its stagger resets at line 2, so
   the two lines close TOGETHER rather than one after the other — see intro.js. */
.rad-intro__word {
  font-family: "Archivo", sans-serif;
  font-weight: 630;
  font-variation-settings: "wdth" 116;
  letter-spacing: .045em;
  line-height: 1;
  text-transform: uppercase;
  font-size: 1.5rem;
  color: #ECE9E2;
  margin-top: 1.7rem;
}

/* line 2 — the same Archivo / wdth-116 treatment as line 1, at the 330 weight the
   old "DESIGN" span used, keeping its own muted colour and its own sizes */
.rad-intro__sub {
  font-family: "Archivo", sans-serif;
  font-weight: 330;
  font-variation-settings: "wdth" 116;
  text-transform: uppercase;
  letter-spacing: .045em;
  font-size: 1rem;
  line-height: 1;
  color: #99948A;
  margin-top: .55rem;
}

/* ---------- phones ----------
   Smaller mark, smaller wordmark, tighter gaps. The vertical half of the centring
   is done in JS (--intro-lift above) because it depends on the measured text
   height; everything here is the static part. Breakpoint MUST stay in step with
   MOBILE_Q in intro.js. */
@media (max-width: 760px) {
  .rad-intro__logo {
    left: calc(50% - 60px);
    top: calc(50% - 60px + var(--intro-lift, 0px));
    width: 120px;
    height: 120px;
  }
  .rad-intro__text {
    top: calc(50% + 60px + var(--intro-lift, 0px));
  }
  .rad-intro__word { font-size: 1.15rem; margin-top: 1.3rem; }
  .rad-intro__sub  { font-size: .85rem; margin-top: .45rem; }
}

/* Hide the real header mark from the very first paint so the intro logo is the only
   R on screen until the intro hands it over. body:has(.hero) keeps this to the
   homepage; under reduced motion the whole intro is skipped, so the mark stays visible.

   ---- Round 14c: why this rule is still here, and why it now switches off EARLY ----
   The client kept seeing the ending "pop". Rounds 11-13 treated that as a duration
   problem and stretched the landing crossfade 450 -> 700ms; it popped anyway, because
   the structure was wrong rather than the number. A crossfade is TWO animations that
   have to stay in lockstep — the intro's logo fading out while this mark fades in — and
   any frame either one misses shows up as a step, which is precisely why it read worst
   on the browser that drops frames.
   So there is no crossfade any more. intro.js brings this mark to full opacity at the
   instant the logo flight BEGINS, roughly a second and a half before the landing, over a
   short 300ms fade — at that moment the corner is under a 96%-opaque scrim and the intro
   logo is still at the centre of the screen, so the transition is invisible BY
   CONSTRUCTION rather than by being fast. When the fade finishes, intro.js adds
   `rad-intro-done`, cancels its own animation in the same task and never touches this
   element again. From then on `.brand .mark` computes exactly as it does on a page that
   never ran the intro: no animation, no inline style, no class swap pending, so :hover
   and body:has(.hero:hover) behave normally.
   At the landing there is then exactly ONE animation left in the whole overlay — the
   intro logo's own opacity 1 -> 0 — dissolving to reveal a mark that has been sitting
   there, at full opacity and pixel-registered to ~0.003px, for well over a second.
   Nothing has to stay in lockstep with anything, so there is nothing left to pop.
   Specificity note, unchanged: with :not() this rule is (0,4,1) and site.css's
   `.brand .mark` is (0,2,0), so the hide wins while the class is absent; once the class
   is present the selector simply stops matching. Every skip path (reduced motion, no
   WAAPI, arrival gate, session gate) adds the class immediately, so a skipped intro
   means a fully opaque mark on the first paint with nothing animating. */
@media (prefers-reduced-motion: no-preference) {
  body:has(.hero):not(.rad-intro-done) .brand .mark { opacity: 0; }
}
