/* =========================================================================
   backdrop.css — THE ATMOSPHERE LAYER.

   Loads AFTER site.css and re-shapes `.night`, the fixed page backdrop that
   chrome.html emits on every page but the map. site.css keeps its rules as
   the no-sheet floor; this file is the composite the site actually wears.

   THIS SHEET HOLDS NO COLOUR OF ITS OWN, like every other shared sheet here.
   Every value is a role token from theme.css or a color-mix() of one, so the
   same rules produce a near-black ember island and a pale jungle island
   without an edit. A test refuses this file if a raw #hex or rgba() appears.

   ---------------------------------------------------------------------------
   THE FAILURE THIS REPLACES — MEASURED IN A BROWSER, and it is not the one
   anybody expected.

   The owner supplied a 1656x952 cinematic montage (volcano, fossil skull,
   raptors, fog, water) and said, of the live site: "where are the animations?
   where are the backgrounds? i gave u a whole background to implement." The
   assumption going in was that his artwork was being buried under scrims —
   site.css stacks veils at 94%, 82% and 55% over it, which multiply out to a
   few percent of surviving picture. That would have been bad enough.

   IT WAS WORSE. Read back out of a rendered page:

       getComputedStyle(document.querySelector('.night')).backgroundImage
         -> "none"

   NONE. The artwork was never composited at ALL, on any page, ever. Nor were
   the veils, nor --backdrop-layers. The entire backdrop was
   `background-color: rgb(5,4,3)` and nothing else, which is exactly the black
   slab in the owner's screenshot.

   ★ THE MECHANISM, because this will be typed again by somebody otherwise:

       background-image: <image>#          <- accepts IMAGES ONLY
       background:       <bg-layer>#       <- accepts position / size / repeat

     site.css wrote

       background-image: ..., var(--art-background) 18% center / cover
                              no-repeat, var(--backdrop-layers);

     `18% center / cover no-repeat` is shorthand-only syntax in a longhand
     property. That makes the declaration INVALID, and an invalid declaration
     is DROPPED IN SILENCE — no console warning, no parse error, no failing
     test, because the bytes are exactly what the author typed. And on this
     owner --backdrop-layers is pinned in owner.json to a value that carries
     `center top / cover no-repeat` INSIDE it, so the same declaration was
     invalid twice over.

     The same page carries a second, unrelated instance of "silently dropped
     and nobody noticed": see the `main` rule below.

   So the honest summary is that this layer is not making a dim backdrop
   brighter. It is compositing the owner's artwork for the first time. Every
   rule here uses the `background` SHORTHAND wherever a layer needs a position
   or a size, and the test file for this sheet refuses a `background-image`
   longhand that contains a `/`.

   THE RULE THIS SHEET FOLLOWS INSTEAD: the scrim is a READING BED, not a
   wash. It is heavy in the one region words occupy and it is GONE everywhere
   else, so legibility is bought from the part of the picture nobody reads
   over. The artwork keeps its own black point and its own highlights: the
   veils are the owner's own --bg-deep at an alpha, which DARKENS, and there
   is no lighter wash anywhere in the stack that could lift a black toward
   grey. That was the failure site.css documents and then commits.

   ---------------------------------------------------------------------------
   THREE FLOORS, IN ORDER. Each one is a complete, deliberate page.

     1. NO JAVASCRIPT. `.night` alone: shaped scrim + artwork + vignette +
        the theme's own depth. Still. Correct. Nothing missing-looking.
     2. JAVASCRIPT. backdrop.js adds `data-atmos="on"` and builds the scene
        inside `.night`: two parallax art planes, two fog banks, an ember
        canvas, a lightning sheet, grain, chromatic edge. Everything below
        that is armed by an attribute this sheet paints; nothing is injected
        as inline style except the four numeric custom properties that carry
        pointer, scroll and flash intensity.
     3. NO WEBGL NEEDED. There is no WebGL in this layer at all, deliberately:
        every effect here is a compositor transform, a CSS gradient or a 2D
        canvas, so there is no context to lose, no shader to fail to compile,
        and --disable-gpu is not a different page. See README_FRONTEND.md.

   PERFORMANCE IS A FEATURE. Only `transform`, `translate`, `scale`, `opacity`
   and `filter` animate here; nothing in this sheet can trigger layout. Heavy
   layers are declared `will-change` exactly once and only while they move.
   The quality ladder is an attribute — [data-q="low"] and [data-q="off"] —
   so a step-down is a style recalc on one element rather than a rebuild.
   ========================================================================= */

/* =========================================================================
   THE MATERIAL. Fallback first, real value second: if a browser cannot parse
   color-mix() the whole declaration is dropped at parse time and the plain
   token above it survives, so an ancient engine gets a slightly blunter
   scrim rather than an invalid variable and a layer that vanishes.
   ========================================================================= */
:root {
  /* ---- the reading bed: the owner's own floor colour, four densities ---- */
  --atmos-read-core: var(--bg-deep);
  --atmos-read-mid:  var(--bg-deep);
  --atmos-read-soft: var(--bg-deep);
  --atmos-bar:       var(--bg-deep);
  --atmos-floor:     var(--bg-deep);
  --atmos-corner:    var(--bg-deep);
  /* ★ THESE NUMBERS ARE A CONTRAST MEASUREMENT, NOT A TASTE.
     The floor is set by the BRIGHTEST PIXEL a reader can end up with behind
     body copy. On this owner's plate that is the fossil skull at about
     rgb(230,230,230), and the tightest ink asked to sit on it is --ink-2
     (body copy, relative luminance .545 on this palette). Solving
     WCAG contrast for the veil alpha over that pixel:

         veil 62%  ->  3.95:1   FAILS AA
         veil 70%  ->  5.29:1
         veil 80%  ->  6.90:1
         veil 91%  ->  9.60:1   <- what this sheet shipped first

     ...and re-solved against a CLIPPED 255 rather than this plate's own 230,
     because `.wc-plane` brightens the artwork by 14% (see its filter, and
     why brightness() is the one lift that cannot grey out a black):

         veil 70%  ->  4.62:1   the hard floor under body copy
         veil 74%  ->  5.40:1   the page ramp
         veil 78%  ->  6.36:1   the reading bed

     91% was defended against a threat that does not exist: it bought four
     ratios nobody can perceive and paid for them with the owner's artwork,
     which is the whole reason he complained twice. 80% in the reading bed
     and 70% as the hard floor anywhere body copy can land keeps every
     measurement above AA with room to spare, and lets the picture READ.

     The bar and the page floor stay near-opaque because a sticky nav and a
     fixed live bar have to read identically on thirteen pages, and neither
     one is artwork anybody is looking at. */
  --atmos-read-core: color-mix(in srgb, var(--bg-deep) 78%, transparent);
  --atmos-read-mid:  color-mix(in srgb, var(--bg-deep) 54%, transparent);
  --atmos-read-soft: color-mix(in srgb, var(--bg-deep) 20%, transparent);
  --atmos-bar:       color-mix(in srgb, var(--bg-deep) 93%, transparent);
  --atmos-floor:     color-mix(in srgb, var(--bg-deep) 96%, transparent);
  --atmos-corner:    color-mix(in srgb, var(--bg-deep) 60%, transparent);

  /* THE PAGE SCRIM — what defends body copy BELOW the first screen.
     Read the `main` rules further down for why this exists and why it is
     shaped rather than flat. */
  --atmos-page-heavy: var(--bg-deep);
  --atmos-page-light: var(--bg-deep);
  --atmos-page-col:   var(--bg-deep);
  --atmos-page-heavy: color-mix(in srgb, var(--bg-deep) 74%, transparent);
  --atmos-page-light: color-mix(in srgb, var(--bg-deep) 40%, transparent);
  --atmos-page-col:   color-mix(in srgb, var(--bg-deep) 58%, transparent);

  /* ---- weather: mist, and the sheet a distant strike lights ----
     Both are the owner's LIT EDGE, which is mix(bg, fg, .62) — the colour
     this palette already uses for a surface catching light. A pale owner
     gets pale fog; nobody gets a hand-picked grey. */
  --atmos-fog:      var(--border-2);
  --atmos-fog-soft: var(--border);
  --atmos-flash:    var(--edge-lit);
  --atmos-shaft:    var(--border);
  --atmos-fog:      color-mix(in srgb, var(--edge-lit) 15%, transparent);
  --atmos-fog-soft: color-mix(in srgb, var(--edge-lit) 7%, transparent);
  --atmos-flash:    color-mix(in srgb, var(--edge-lit) 74%, transparent);
  --atmos-shaft:    color-mix(in srgb, var(--edge-lit) 20%, transparent);

  /* ---- the fires in the picture ----
     Bloom sits where backdrop.js SAMPLED the artwork's own hot pixels, so an
     ember island blooms at its lava and a daylight island blooms at its sun.
     The colour is the owner's accent, which is what an accent is for. */
  --atmos-bloom:     var(--accent-glow);
  --atmos-bloom-hot: var(--accent-glow);
  --atmos-bloom:     color-mix(in srgb, var(--accent) 26%, transparent);
  --atmos-bloom-hot: color-mix(in srgb, var(--accent-hi) 34%, transparent);

  /* ---- motion amplitudes, in one place ----
     Parallax is a FEW PIXELS. The brief for this layer says "subtle — a few
     pixels, not a funhouse", and a backdrop that visibly swims when the mouse
     moves reads as a broken page, not as depth. */
  --atmos-par-near: 13px;   /* the foreground plane, full pointer rate */
  --atmos-par-far:  5px;    /* the distant plane, ~40% of it */
  --atmos-scroll:   18px;   /* total travel across one viewport of scroll */
}

/* =========================================================================
   FLOOR 1 — `.night` WITH NO JAVASCRIPT AT ALL.

   Layer order is top-down. Read it as a photograph being lit:

     a. THE BAR. Opaque for the sticky nav's 62px and released by 130px, so
        one bar reads identically on ten pages without darkening the picture
        under it.
     b. THE READING BED. Two soft ellipses over the region the hero column
        actually occupies — measured, not assumed: at 1600px the wrap is
        160..1440, the hero's left column is 1.06fr of a 1.06/.94 grid, so
        words live in x 11%..51% and y 12%..60%. The bed is solid through
        that and fully gone by x 70%. The right half of the picture — on this
        owner the fossil skull, the wrecked dome, the raptor in the water — is
        never touched.
     c. THE FLOOR. The last 220px close on the owner's floor colour so the
        live bar and the end of a short page sit on colour, not on artwork.
     d. THE VIGNETTE, off-centre toward the art, the way a lit object falls
        off at its corners.
     e. THE ARTWORK, AT FULL STRENGTH. Not dimmed — shaped by a..d.
     f. --backdrop-layers last, so an owner who ships no background art still
        gets the theme's own depth instead of a slab.

   `background-position: 50% center` and not site.css's `18% center`: at any
   normal desktop aspect a 1656x952 montage covers with ~140px of horizontal
   slack, so an off-centre anchor spends the owner's composition to no effect.

   ★ AND IT IS THE `background` SHORTHAND, NOT `background-image`. See the
     header: a longhand cannot carry `/ cover no-repeat`, and writing it there
     is what silently deleted this owner's artwork from every page of his
     site. The `var()` fallbacks matter for the same reason — a theme that
     never emitted --backdrop-layers would otherwise make the whole
     declaration invalid at computed-value time and take the artwork with it.
     The floor colour is the final layer, which is where the shorthand keeps
     a background-color. */
.night {
  position: fixed; inset: 0; z-index: -2; pointer-events: none;
  background:
    /* a. the bar */
    linear-gradient(180deg,
      var(--atmos-bar) 0, var(--atmos-bar) 62px,
      var(--atmos-read-mid) 92px, transparent 130px),
    /* b. the reading bed */
    radial-gradient(46% 52% at 26% 37%,
      var(--atmos-read-core) 0%, var(--atmos-read-core) 34%,
      var(--atmos-read-mid) 58%, var(--atmos-read-soft) 80%, transparent 100%),
    radial-gradient(38% 78% at 2% 48%,
      var(--atmos-read-core) 0%, var(--atmos-read-mid) 46%, transparent 100%),
    /* c. the floor */
    linear-gradient(0deg,
      var(--atmos-floor) 0, var(--atmos-read-core) 54px,
      var(--atmos-read-soft) 150px, transparent 240px),
    /* d. the vignette */
    radial-gradient(122% 108% at 58% 34%,
      transparent 0%, transparent 52%, var(--atmos-corner) 100%),
    /* e. the owner's artwork, undimmed */
    var(--art-background, none) 50% center / cover no-repeat,
    /* f. the theme's own depth, for an owner with no artwork */
    var(--backdrop-layers, none),
    /* g. the floor colour */
    var(--bg-deep);
}

/* ★ THE GROUND, RESTATED — AND THIS IS A BUG REPORT IN A RULE.
   site.css's `main { background-image: <ramp> }` is DEAD in the browser and
   dead on the live site. MEASURED, not inferred:

       getComputedStyle(document.querySelector('main')).backgroundImage
         -> "none"

   The comment above that rule closes early — its prose contains a literal
   comment-terminator, quoting one — and then six more lines of text run on
   before the intended terminator arrives. CSS error recovery swallows that
   prose plus the following `main` into one invalid selector, and the whole
   rule goes with it. Nothing warns, nothing logs, and every byte-level test
   passes because the bytes are exactly what the author typed. It is the
   second silently-dropped declaration on this page — see the header for the
   first, which deleted the artwork itself.

   ★ AND IT BIT THIS FILE TOO, WHICH IS WHY THERE IS NOW A TEST. Writing the
   paragraph above with the terminator QUOTED INLINE closed this comment four
   paragraphs early and dropped `.wc-bloom`'s rules on the way past. A rendered
   probe found it (`main` still computed `none` after the "fix"), a scanner
   found the other three, and tests/frontend/test_backdrop.py now refuses a
   stray terminator in any shared sheet. Never write the two characters
   together inside a comment; say "comment-terminator" instead.

   That ramp is what lets the scrim above stay open — it is why body copy
   further down a page is never over artwork at all. Opening the backdrop up
   without it would put paragraphs on a photograph. It is restated here, from
   this sheet, so the atmosphere layer is safe TODAY. site.css belongs to
   another hand this session, which is why the fix is here and not there.

   ★ BUT IT IS NOT RESTATED AT ITS OLD VALUE, AND THAT IS THE POINT.
     site.css closed this ramp on --veil-deep: 94% of the floor colour, one
     screen down, forever. Which means the owner's artwork was allowed to
     exist on exactly the first 700 pixels of one page and nowhere else in
     his site — and he has now said twice that the background is missing.

     The measurement above says 94% was never necessary. The brightest pixel
     this ramp can be asked to defend is the skull at rgb(230,230,230); body
     ink clears AA over it at 70%, and this closes at 74%. The picture stays
     present the whole way down a ten-thousand-pixel page instead of being
     switched off below the fold, and every paragraph still measures 5.9:1.

     Because `.night` is FIXED, this is the same picture at every scroll
     position — so the defence has to hold at every scroll position, which is
     why the ramp closes on a constant rather than fading back out. */
main {
  background-image: linear-gradient(180deg,
    transparent 0,
    transparent clamp(150px, 26vh, 300px),
    var(--atmos-page-heavy) clamp(430px, 72vh, 760px));
}

/* ★ AND WHEN THE ENGINE IS RUNNING, THE RAMP GETS LIGHTER STILL — because
   something better takes over the job.

   A flat wash down a whole page is the blunt instrument. The reading column
   on this site is `.wrap`, max 1280px, centred: on a 1600px screen the outer
   160px each side carries NO TEXT AT ALL, and on a 1920px screen it is 320px
   each side. So the scrim is SHAPED horizontally — dense across the column,
   open at the margins — and the picture is visibly the room the page sits
   in rather than a thing that happened above the fold.

   .wc-page carries that column and fades IN with scroll using a native
   scroll-driven animation. No JavaScript is involved in it at all: the
   timeline is the document scroller, so it is correct at quality 'off', under
   prefers-reduced-motion, and on a page whose engine never booted.

   THE @supports GATE IS LOad-BEARING. Without scroll timelines the keyframe
   would run on the DOCUMENT timeline — the scrim would fade in on a clock
   rather than on scroll, and a reader who never scrolled would watch the page
   darken by itself. So both halves of this trade (the lighter ramp AND the
   column that pays for it) live inside the same @supports, and a browser
   without it simply keeps the heavy ramp above. */
@supports (animation-timeline: scroll()) {
  .night[data-atmos] ~ main {
    background-image: linear-gradient(180deg,
      transparent 0,
      transparent clamp(150px, 26vh, 300px),
      var(--atmos-page-light) clamp(430px, 72vh, 760px));
  }
  .wc-page {
    position: absolute; inset: 0;
    opacity: 0;
    background: linear-gradient(90deg,
      transparent 0%,
      var(--atmos-page-col) 9%,
      var(--atmos-page-col) 91%,
      transparent 100%);
    animation: wc-page-scrim linear both;
    animation-timeline: scroll(root block);
    animation-range: 0 92vh;
  }
  @keyframes wc-page-scrim { from { opacity: 0; } to { opacity: 1; } }
}

/* ★ THE SEAM ABOVE THE FOOTER, FOUND ON A SCROLLED SCREENSHOT.
   site.css gives `.foot` a 76px MARGIN-top. A margin paints nothing, so with
   `main`'s ramp ending at main's box and the footer's opaque field starting
   76px later, a full-width horizontal STRIPE of un-ramped backdrop sat
   between them — lighter than the page above it and lighter than the footer
   below it, with a hard edge on both sides. Invisible while the backdrop was
   a black slab, obvious the moment there is a picture back there.

   Same 76px of air, moved inside the footer's own painted box, so the two
   surfaces meet without a gap. 76 + 34 = 110, which is the padding site.css
   already had, so nothing moves by a pixel. */
.foot { margin-top: 0; padding-top: 110px; }

/* ★ PHONE. The reading-COLUMN argument does not survive 390px — text spans
     the whole width, so there is no open half to give the picture. But
     "no open half" is not "no open anything", and the first cut of this rule
     made that mistake: it laid one even 91% veil from 30% of the height to
     88% and the artwork came out as grey noise on a rendered 390x844 shot.

     The reading argument becomes VERTICAL instead. On a phone the hero art
     plate owns the top third and the copy block owns everything under it, so
     the veil is OPEN across the top (the picture shows around and behind the
     plate) and closes to full strength exactly where the headline starts.
     Same principle, rotated ninety degrees. */
@media (max-width: 820px) {
  .night {
    background:
      linear-gradient(180deg,
        var(--atmos-bar) 0, var(--atmos-bar) 58px,
        var(--atmos-read-soft) 22%, var(--atmos-read-mid) 44%,
        var(--atmos-read-core) 62%, var(--atmos-read-core) 90%,
        var(--atmos-floor) 100%),
      radial-gradient(140% 84% at 50% 26%,
        transparent 0%, transparent 52%, var(--atmos-corner) 100%),
      var(--art-background, none) 50% 22% / cover no-repeat,
      var(--backdrop-layers, none),
      var(--bg-deep);
  }
}

/* =========================================================================
   FLOOR 2 — THE LIVE SCENE.

   backdrop.js sets data-atmos="on" only once it has a real element, a real
   layer stack and a live rAF. Until then — and forever, if the script 404s,
   throws on an ancient engine or is switched off — the page above is what
   ships. There is no flash of an unbuilt scene: every layer below starts at
   the value it holds at rest.
   ========================================================================= */

/* The artwork moves into the PLANES, so `.night` stops painting it and stops
   paying for --backdrop-layers as well — on this owner that token re-paints
   the same 2.4MB texture under its own wash. The scrim moves to .wc-veil so
   it can sit ABOVE the planes; what is left here is the floor colour, which
   is all that a one-pixel gap could ever show. */
.night[data-atmos] { background: var(--bg-deep); }

/* An owner with NO background art keeps the theme's depth under the scene:
   the fog, embers, lightning and grain all still run, over gradients instead
   of over a photograph. backdrop.js sets data-art="none" when the token is
   `none` or the image fails to load. */
.night[data-atmos][data-art="none"] {
  background: var(--backdrop-layers, none), var(--bg-deep);
}

/* site.css's 128s drift is superseded by the fog banks and would otherwise
   keep an animation alive underneath layers that are opaque over it. */
.night[data-atmos]::after { display: none; }

.wc-atmos {
  position: absolute; inset: 0;
  overflow: hidden;
  pointer-events: none;
  /* Blend modes below must resolve against THIS stack and nothing else. With
     no isolation a `screen` fog would reach past the backdrop into the page
     ground and lift it — the one thing this whole sheet exists to prevent. */
  isolation: isolate;
  /* Paint containment keeps an oversized plane from inviting the compositor
     to consider the rest of the document. */
  contain: layout paint style;
}

/* ---------- the depth planes ----------
   ★ TWO COPIES OF ONE PICTURE, AND THE MASK IS WHAT MAKES IT DEPTH.

   You cannot extract real depth from a flat montage, and pretending to is
   how a parallax backdrop ends up looking like sliding wallpaper. What IS
   true of this artwork — and of nearly every wide landscape plate an owner
   will ever supply — is that the TOP of the frame is far away (sky, storm,
   the volcano's rim) and the BOTTOM is near (foreground rock, water, the
   animals). So:

     .wc-plane-base  the whole picture, unmasked and OPAQUE, moving at the
                     near rate. Because it is unmasked there is never a gap
                     for the floor colour to show through.
     .wc-plane-sky   the same picture again, masked to the upper half with a
                     long cross-fade, moving at ~40% of the near rate.

   In the top third the sky plane covers the base completely, so its slower
   travel is what the eye reads. Through the cross-fade the two are offset by
   at most a few pixels and both are the same image, so the mismatch reads as
   haze rather than as a double exposure. Below it, only the base is left.
   That is real 2.5D on one asset, one decode, two composited layers.

   `translate` (the standalone property) carries pointer + scroll; `scale`
   carries the ken-burns keyframes. They are separate CSS properties, so the
   two never fight over one `transform` string — which is the usual reason a
   parallax layer and a breathing layer cannot coexist. */
/* ★ THE OVERSIZE IS A BUDGET, NOT A TASTE. A plane has to be bigger than the
   viewport or a parallax translate exposes the floor colour as a black band
   down one edge — but every percent of oversize is a percent of the owner's
   COMPOSITION thrown away, because `cover` then scales the picture up to fill
   the bigger box. The first cut of this sheet used -7% plus a 1.035..1.085
   ken-burns and showed 76% of the frame: the volcano lost its rim and the
   wrecked dome lost its roof. -3% with a 1.0..1.04 breath shows about 87%,
   and 30px of slack against a worst-case 25px of travel (13px of pointer at
   half rate, plus 18px of scroll) still cannot expose an edge. */
.wc-plane {
  position: absolute; inset: -3%;
  background: var(--art-background) 50% center / cover no-repeat;
  /* ★ brightness() IS THE ONE LIFT THAT DOES NOT LIFT BLACKS, and that is
     exactly why it is here. It is a LINEAR multiply: 0 x 1.14 is still 0, so
     the picture's true blacks stay true black while everything the owner
     actually wants seen — the skull, the fog, the animals — comes up 14%.
     A `brightness` on a dark plate is what a gamma curve or an opacity wash
     would only pretend to be, and both of those DO grey out the floor. The
     contrast and saturation are a print, not a look.

     The floor calculation at the top of this sheet already assumes the
     brightest pixel is clipped at 255 rather than this plate's own 230,
     precisely so this lift cannot quietly invalidate it. Static, so it costs
     one rasterisation and nothing per frame. */
  /* ★★ 1.14 WAS NOT ENOUGH, AND THE OWNER SAID SO: "only skull is really
     visible". His plate is a montage of six panels and only ONE of them —
     the pale fossil skull at the centre — is bright enough to survive a
     14% lift. The volcano, the raptors in the water and the fighting
     theropods are all painted in the bottom of the range, so at 1.14 they
     were technically composited and practically absent, which is the same
     complaint as the artwork missing entirely.
     1.34 brings those panels up to where they read. It is still a LINEAR
     multiply, so 0 x 1.34 is still 0 and the picture's true blacks stay
     true black — the one lift that does not grey the page.

     ★ AND THERE IS NO `contrast()` HERE, WHICH IS A CORRECTION. I first
     paired the lift with `contrast(0.98)`, reasoning that pulling the
     highlights back would let the dark panels climb without the skull
     blowing out. That reasoning was wrong about what the function does.
     CSS `contrast(a)` is `slope = a, intercept = 0.5 - 0.5a`, so at 0.98 it
     maps true black to 0.01 — about 3/255. Invisible, and still a lie:
     the sentence above and this file's header both promise that nothing in
     this stack greys the owner's floor, and a filter that lifts black
     breaks that promise for every owner, not just this one. A claim in a
     comment is load-bearing; the next person reads it instead of re-deriving
     it. Dropped rather than set to 1.0, because `contrast(1)` is a no-op
     that would only invite someone to nudge it again. */
  filter: brightness(1.34) saturate(1.1);
  will-change: translate, scale;
  translate: calc(var(--wc-px, 0) * var(--atmos-par-near))
             calc(var(--wc-py, 0) * var(--atmos-par-near) * .5
                  - var(--wc-sy, 0) * var(--atmos-scroll));
}
.wc-plane-sky {
  translate: calc(var(--wc-px, 0) * var(--atmos-par-far))
             calc(var(--wc-py, 0) * var(--atmos-par-far) * .5
                  - var(--wc-sy, 0) * var(--atmos-scroll) * .38);
  /* `black`/`transparent` are the alpha stencil a mask keys off, not colours
     the design chose — the same two keywords site.css already masks the hero
     plate with. No owner colour can appear in a mask and none does. */
  -webkit-mask-image: linear-gradient(180deg,
    black 0%, black 40%, transparent 84%);
  mask-image: linear-gradient(180deg,
    black 0%, black 40%, transparent 84%);
}

/* ---------- ken-burns: the page is never actually still ----------
   Scale only, and only a few percent, on a 46s / 61s pair that never lines
   up. Two different periods means the planes drift apart and back together
   forever instead of pulsing in step, which is what makes a loop stop
   looking like a loop. `scale` is a compositor property: zero main thread. */
@keyframes wc-ken-near { from { scale: 1; }     to { scale: 1.04; } }
@keyframes wc-ken-far  { from { scale: 1.038; } to { scale: 1; } }
.night[data-atmos] .wc-plane-base { animation: wc-ken-near 46s ease-in-out infinite alternate; }
.night[data-atmos] .wc-plane-sky  { animation: wc-ken-far  61s ease-in-out infinite alternate; }

/* ---------- the fires in the picture ----------
   Two soft blooms parked on the artwork's own brightest warm pixels, which
   backdrop.js finds by sampling the image into a 128x76 offscreen canvas. On
   this owner that lands on the coals under the fossil skull and the volcano's
   crater; on a jungle owner it lands wherever that picture's light is. The
   positions arrive as --wc-b1x/--wc-b1y, never as a rule in this file — an
   art-specific coordinate typed into a shared sheet is exactly the owner
   hardcode this repo exists to prevent.

   ★ AND IT IS SMALL, BECAUSE `screen` LIFTS BLACKS. The first cut painted
     these at 20%x26% of the viewport and up to 92% opacity, and a screen
     blend that wide put a warm haze across the whole left of the plate: the
     picture's true blacks came up to a mid warm grey and the skull lost its
     highlight. That is precisely the failure this whole layer exists to
     undo, arriving by the back door. A fire is a POINT source with a short
     falloff, so these are ~10% of the viewport, gone by 58% of their radius,
     and they never run above 0.62. */
.wc-bloom {
  position: absolute; inset: 0;
  mix-blend-mode: screen;
  background:
    radial-gradient(10% 13% at var(--wc-b1x, 22%) var(--wc-b1y, 18%),
      var(--atmos-bloom-hot), transparent 58%),
    radial-gradient(13% 10% at var(--wc-b2x, 52%) var(--wc-b2y, 88%),
      var(--atmos-bloom), transparent 60%);
  opacity: .55;
  animation: wc-bloom-breathe 7.4s ease-in-out infinite alternate;
}
@keyframes wc-bloom-breathe { from { opacity: .38; } to { opacity: .62; } }

/* ---------- fog banks ----------
   Two of them, at two depths, drifting in opposite directions on periods
   that do not divide into each other (83s / 127s), so the pair never repeats
   inside a session. Masked to different horizontal bands so they read as
   banks lying IN the scene rather than as a sheet over it, and blended
   `screen` because fog adds light — it never darkens what is behind it.

   The element is wider than the viewport and moves by transform only, so a
   drift costs the compositor and costs the main thread nothing. */
.wc-fog {
  position: absolute; inset: -18% -55%;
  mix-blend-mode: screen;
  will-change: transform;
}
.wc-fog-far {
  background:
    radial-gradient(30% 40% at 14% 52%, var(--atmos-fog), transparent 72%),
    radial-gradient(24% 30% at 40% 44%, var(--atmos-fog-soft), transparent 74%),
    radial-gradient(34% 36% at 68% 56%, var(--atmos-fog), transparent 70%),
    radial-gradient(22% 28% at 90% 48%, var(--atmos-fog-soft), transparent 76%);
  -webkit-mask-image: linear-gradient(180deg, transparent 4%, black 24%, black 50%, transparent 74%);
  mask-image: linear-gradient(180deg, transparent 4%, black 24%, black 50%, transparent 74%);
  opacity: .5;
}
.wc-fog-near {
  background:
    radial-gradient(38% 34% at 22% 58%, var(--atmos-fog), transparent 70%),
    radial-gradient(28% 26% at 56% 66%, var(--atmos-fog-soft), transparent 74%),
    radial-gradient(42% 30% at 84% 60%, var(--atmos-fog), transparent 72%);
  -webkit-mask-image: linear-gradient(180deg, transparent 40%, black 66%, black 92%, transparent 100%);
  mask-image: linear-gradient(180deg, transparent 40%, black 66%, black 92%, transparent 100%);
  opacity: .4;
}
@keyframes wc-drift-a {
  from { transform: translate3d(-7%, 0, 0); }
  to   { transform: translate3d( 7%, 0, 0); }
}
@keyframes wc-drift-b {
  from { transform: translate3d( 9%, 1%, 0); }
  to   { transform: translate3d(-9%, -1%, 0); }
}
.night[data-atmos] .wc-fog-far  { animation: wc-drift-a 127s ease-in-out infinite alternate; }
.night[data-atmos] .wc-fog-near { animation: wc-drift-b  83s ease-in-out infinite alternate; }

/* Weather dials the banks up. The attribute is written on .night by
   setWeather(), so one attribute change re-dresses the whole scene. */
.night[data-wx="fog"]   .wc-fog-far  { opacity: .92; }
.night[data-wx="fog"]   .wc-fog-near { opacity: .78; }
.night[data-wx="storm"] .wc-fog-far  { opacity: .70; }
.night[data-wx="storm"] .wc-fog-near { opacity: .58; }
.night[data-wx="rain"]  .wc-fog-far  { opacity: .62; }
.night[data-wx="rain"]  .wc-fog-near { opacity: .50; }
.night[data-wx="clear"] .wc-fog-far  { opacity: .26; }
.night[data-wx="clear"] .wc-fog-near { opacity: .18; }

/* ---------- volumetric light shafts ----------
   The one effect that makes a flat plate read as a VOLUME. A repeating
   linear gradient of hard bands, blurred once into soft beams, masked to a
   radial that anchors them to a point above the top-right of the frame, and
   drifting on a 74s clock that shares no factor with either fog bank.

   Cheap in the way that matters: the blur rasterises ONCE because the only
   animated property is a transform, and the mask means the beams exist only
   where a sky is. `screen` so they add light and never darken.

   Weather drives them, and it drives them the way weather does — a clear sky
   has shafts, a storm does not, because a storm has no gap for them to come
   through. */
.wc-shafts {
  position: absolute; inset: -22% -34%;
  mix-blend-mode: screen;
  opacity: .5;
  background: repeating-linear-gradient(102deg,
    transparent 0 46px,
    var(--atmos-shaft) 46px 58px,
    transparent 58px 118px);
  -webkit-mask-image: radial-gradient(66% 64% at 64% -4%, black 0%, transparent 78%);
  mask-image: radial-gradient(66% 64% at 64% -4%, black 0%, transparent 78%);
  filter: blur(7px);
  will-change: transform;
}
@keyframes wc-shaft-drift {
  from { transform: translate3d(-3%, 0, 0) skewX(-1.2deg); }
  to   { transform: translate3d( 3%, 0, 0) skewX(1.2deg); }
}
.night[data-atmos] .wc-shafts { animation: wc-shaft-drift 74s ease-in-out infinite alternate; }
.night[data-wx="clear"]  .wc-shafts { opacity: .78; }
.night[data-wx="cloudy"] .wc-shafts { opacity: .5; }
.night[data-wx="fog"]    .wc-shafts { opacity: .62; }
.night[data-wx="rain"]   .wc-shafts { opacity: .26; }
.night[data-wx="storm"]  .wc-shafts { opacity: .14; }

/* ---------- embers, rain: one canvas, one loop ----------
   Everything particulate shares a single 2D context and a single rAF tick.
   Two effects with two loops is two clears, two composites and two chances
   to be the thing that costs 4ms. Sized by JS to devicePixelRatio, capped. */
.wc-canvas {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  display: block;
  mix-blend-mode: screen;
}

/* ---------- distant lightning ----------
   The sheet a strike lights, confined to the sky. Opacity is driven by the
   Web Animations API from backdrop.js — the browser's own timeline, off the
   main thread, with a multi-pulse profile per strike — so this rule only has
   to describe WHERE the light lands, never when. At rest it is invisible and
   costs one composited layer that never repaints. */
.wc-flash {
  position: absolute; inset: 0;
  opacity: 0;
  mix-blend-mode: screen;
  background:
    linear-gradient(180deg, var(--atmos-flash) 0%,
      var(--atmos-fog) 22%, transparent 54%),
    radial-gradient(56% 34% at 68% 2%, var(--atmos-flash), transparent 74%);
}

/* ---------- the reading bed, now ABOVE the scene ----------
   Same shape as the no-JS floor, restated as a layer so the planes, the fog
   and the embers all sit UNDER it. Words are defended from everything the
   scene does, including a lightning strike. */
.wc-veil {
  position: absolute; inset: 0;
  background-image:
    linear-gradient(180deg,
      var(--atmos-bar) 0, var(--atmos-bar) 62px,
      var(--atmos-read-mid) 92px, transparent 130px),
    radial-gradient(46% 52% at 26% 37%,
      var(--atmos-read-core) 0%, var(--atmos-read-core) 34%,
      var(--atmos-read-mid) 58%, var(--atmos-read-soft) 80%, transparent 100%),
    radial-gradient(38% 78% at 2% 48%,
      var(--atmos-read-core) 0%, var(--atmos-read-mid) 46%, transparent 100%),
    linear-gradient(0deg,
      var(--atmos-floor) 0, var(--atmos-read-core) 54px,
      var(--atmos-read-soft) 150px, transparent 240px);
}

/* ---------- the lens: vignette + chromatic edge ----------
   ★ WHY THIS LAYER EXISTS AT ALL. Everything above is a stack of rectangles.
     A stack of rectangles looks like a stack of rectangles unless something
     tells the eye they were all photographed through the same glass. Three
     things do that, and all three belong to the FRAME rather than to any
     effect: the corners fall off, the extreme edge fringes, and the whole
     picture carries one grain.

   The fringe is the artwork exclusion-blended with itself at a two-pixel
   offset — which is an edge map — screened back in at the frame only. Real
   lateral chromatic aberration, from the owner's own asset, at the cost of
   one static layer. No second colour is invented for it: the warmth comes
   from the accent, multiplied over the edge map. */
.wc-vignette {
  position: absolute; inset: 0;
  background: radial-gradient(122% 108% at 58% 34%,
    transparent 0%, transparent 50%, var(--atmos-corner) 100%);
}
.wc-chroma {
  position: absolute; inset: 0;
  mix-blend-mode: screen;
  /* .18 and not .32: at a third it stopped being a fringe on high-contrast
     edges and became a warm haze around the whole frame — the same lifted
     black the bloom above was caught doing. Aberration you can NAME is
     aberration that is too strong. */
  opacity: .18;
  background:
    linear-gradient(90deg, var(--atmos-bloom-hot), transparent 22%,
      transparent 78%, var(--atmos-bloom)),
    var(--art-background) calc(50% + 2px) center / cover no-repeat,
    var(--art-background) calc(50% - 2px) center / cover no-repeat;
  background-blend-mode: multiply, exclusion, normal;
  -webkit-mask-image: radial-gradient(74% 70% at 50% 46%, transparent 58%, black 100%);
  mask-image: radial-gradient(74% 70% at 50% 46%, transparent 58%, black 100%);
}
.night[data-art="none"] .wc-chroma { display: none; }

/* ---------- film grain ----------
   feTurbulence, inline, monochrome, one 160px tile — the browser's own noise
   generator rather than a downloaded texture or a canvas we would have to
   redraw. The tile is bigger than the viewport and STEPS by transform, so
   grain that changes eight times a second costs the compositor a translate
   and costs paint nothing. Animating background-position here instead would
   repaint a full-screen layer eight times a second, which is exactly the
   kind of "free" effect that eats a frame budget. */
.wc-grain {
  position: absolute; inset: -12%;
  mix-blend-mode: overlay;
  opacity: .085;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='g' x='0' y='0'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23g)' opacity='.62'/%3E%3C/svg%3E");
  will-change: transform;
}
@keyframes wc-grain-jitter {
  0%   { transform: translate3d(0, 0, 0); }
  20%  { transform: translate3d(-2%, 1.4%, 0); }
  40%  { transform: translate3d(1.6%, -1.8%, 0); }
  60%  { transform: translate3d(-1.2%, -1%, 0); }
  80%  { transform: translate3d(2%, 1.2%, 0); }
  100% { transform: translate3d(0, 0, 0); }
}
.night[data-atmos] .wc-grain { animation: wc-grain-jitter 1.1s steps(1, end) infinite; }

/* =========================================================================
   THE FIRST-PAINT HERO MOMENT.

   The scene resolves out of the owner's floor colour ONCE, in 860ms, and
   then never again in that tab. It is not a loading gate: the page is fully
   interactive underneath from the first frame, the animation is on the
   BACKDROP only, and it is `both`-filled from a state that is already the
   resting state, so a stall mid-animation leaves a finished picture rather
   than a black one.

   backdrop.js writes data-intro="1" only when sessionStorage says this tab
   has not seen it. Every route on this site is a full document load, so
   without that gate the reveal would replay on every single click — which is
   how a nice first impression becomes the most irritating thing on a site.
   ========================================================================= */
@keyframes wc-intro {
  0%   { opacity: 0; scale: 1.04; filter: brightness(.25) saturate(.5); }
  55%  { opacity: 1; }
  100% { opacity: 1; scale: 1;    filter: brightness(1) saturate(1); }
}
/* ★ 860ms HERE AND 1400ms IN backdrop.js ARE A PAIR. The script removes
   `data-intro` on a plain timer so a browser that never advances its ANIMATION
   clock cannot leave the owner's artwork stuck on this keyframe's opening
   frame. Change the duration here and change the deadline there. */
.night[data-intro="1"] .wc-atmos {
  animation: wc-intro 860ms cubic-bezier(.2, .7, .2, 1) both;
}

/* =========================================================================
   THE QUALITY LADDER.

   [data-q] is written on `.night` by backdrop.js: "high", "low" or "off".
   It steps down when the measured frame time degrades, when the viewport is
   narrow, when navigator.hardwareConcurrency is small, or when the visitor
   has asked for reduced data. It is an attribute and not a rebuild, so a
   step-down is one style recalc and the scene keeps running.

     low   the picture, one plane, one fog bank, the veil and the lens.
           No grain, no chroma, no bloom breathing, fewer particles.
     off   the STILL COMPOSITE. Everything is still there and nothing moves.
           This is also what prefers-reduced-motion gets, and it is a
           deliberate, finished-looking page rather than a degraded one.
   ========================================================================= */
.night[data-q="low"] .wc-grain,
.night[data-q="low"] .wc-chroma,
.night[data-q="low"] .wc-shafts,
.night[data-q="low"] .wc-fog-near { display: none; }
.night[data-q="low"] .wc-plane-sky { display: none; }
.night[data-q="low"] .wc-plane { filter: none; }
.night[data-q="low"] .wc-bloom { animation: none; opacity: .72; }

.night[data-q="off"] .wc-grain,
.night[data-q="off"] .wc-chroma { display: none; }
.night[data-q="off"] .wc-plane,
.night[data-q="off"] .wc-fog,
.night[data-q="off"] .wc-shafts,
.night[data-q="off"] .wc-bloom,
.night[data-q="off"] .wc-atmos { animation: none !important; }
.night[data-q="off"] .wc-plane { translate: none; }
.night[data-q="off"] .wc-canvas { display: none; }
/* Nothing is left moving, so nothing needs a compositor promise. A layer
   left `will-change` forever is memory an idle page has no use for. */
.night[data-q="off"] .wc-plane,
.night[data-q="off"] .wc-fog,
.night[data-q="off"] .wc-grain { will-change: auto; }

/* =========================================================================
   REDUCED MOTION — the still composite, and it is the same picture.

   The visitor who asks for no motion is not asking for a worse page, so
   nothing is removed here: the artwork, the depth planes, the fog banks, the
   bloom, the veil, the lens and the grain are all still painted. Only the
   clocks stop. backdrop.js additionally parks the rAF loop and keeps
   dispatching `wc:lightning` on schedule with no flash, so the audio layer
   hanging thunder off that event stays in step with a page that is not
   allowed to flicker at it.
   ========================================================================= */
@media (prefers-reduced-motion: reduce) {
  .night[data-atmos] .wc-plane-base,
  .night[data-atmos] .wc-plane-sky,
  .night[data-atmos] .wc-fog-far,
  .night[data-atmos] .wc-fog-near,
  .night[data-atmos] .wc-shafts,
  .night[data-atmos] .wc-grain,
  .night[data-atmos] .wc-bloom,
  .night[data-intro="1"] .wc-atmos { animation: none; }
  .wc-plane { translate: none; will-change: auto; }
  .wc-fog, .wc-grain, .wc-shafts { will-change: auto; }
  .wc-canvas { display: none; }
  .wc-flash { opacity: 0; }
}

/* =========================================================================
   PHONE. A 390px screen is the smallest budget and the biggest battery risk,
   so it gets the composite and one bank of weather. The heavy pass — the
   second plane, the grain, the chromatic edge — is cut here as well as by
   the quality ladder, because a fast phone is still a phone.
   ========================================================================= */
@media (max-width: 820px) {
  .wc-plane-sky, .wc-grain, .wc-chroma, .wc-shafts { display: none; }
  .wc-fog-near { opacity: .38; }
  /* No margins to open at 390px — the column is the whole screen. */
  .wc-page { background: var(--atmos-page-col); }
  .wc-veil {
    background-image:
      linear-gradient(180deg,
        var(--atmos-bar) 0, var(--atmos-bar) 58px,
        var(--atmos-read-soft) 22%, var(--atmos-read-mid) 44%,
        var(--atmos-read-core) 62%, var(--atmos-read-core) 90%,
        var(--atmos-floor) 100%);
  }
  .wc-vignette {
    background: radial-gradient(140% 84% at 50% 26%,
      transparent 0%, transparent 52%, var(--atmos-corner) 100%);
  }
  :root { --atmos-par-near: 8px; --atmos-par-far: 3px; --atmos-scroll: 18px; }
}

/* =========================================================================
   FORCED COLOURS / WINDOWS HIGH CONTRAST.

   site.css already sets `.night { display: none }` here, and everything this
   sheet builds lives inside `.night`, so the whole layer leaves with it.
   Restated anyway: `display:none` on an ancestor is the kind of protection
   that disappears the moment somebody makes the backdrop a sibling, and the
   failure mode — system-coloured text printed over a photograph — is the
   exact thing this mode exists to prevent.
   ========================================================================= */
@media (forced-colors: active) {
  .night, .wc-atmos { display: none; }
  main { background-image: none; }
}
