/* =========================================================================
   motion.css — THE SITE'S MOTION SHEET.

   The paint for every hook `js/motion.js` arms. It is the whole-site sibling
   of `casinofx.css`: same hand, same rules, applied to the shell instead of
   to a card table.

   THE COMPLAINT THIS ANSWERS. The shell carried three keyframes — `drift`,
   `lb-breathe`, `pulse` — so every page arrived fully formed, all at once,
   and nothing on it ever responded. A page that never moves reads as a
   picture of a site rather than a site.

   ★ THE ONE RULE THIS SHEET IS WRITTEN TO: FAIL OPEN.
     The worst failure available here is not an ugly animation. It is a reveal
     that never fires and leaves a whole section parked at `opacity: 0`
     forever — on a browser with no IntersectionObserver, with JS blocked, in
     a print sheet, on a page that never scrolls, or because one line in
     another agent's script threw before ours ran. Every byte-level test would
     still pass, because an invisible section is valid CSS.

     So NOTHING in this file hides anything until the ROOT ELEMENT carries
     `data-wc-motion="on"`, and motion.js only writes that attribute after it
     has proven — in that same tick — that it owns a working reveal path
     (IntersectionObserver present, rAF present, reduce not asked for). No JS,
     old JS, thrown JS, blocked JS: no attribute, no hidden state, the page is
     exactly the page site.css already drew.

     The second guarantee is TRANSIENCE: `data-wc-reveal` is removed once the
     arrival finishes, so a revealed element carries no residue of this sheet
     at all and cannot fight a hover rule that shares the `transform` channel.

   ★ NO COLOUR OF ITS OWN. Same law as site.css: every colour here is a role
     token from theme.css, or a `color-mix()` OF one. No hex, no rgba(), no
     owner literal. A palette change is one edit in owner.json and zero here.

   ★ TRANSFORM AND OPACITY ONLY. Nothing in this sheet animates width, height,
     top or left, so no effect can push the page into a layout pass. The nav
     "condense" is therefore paint plus a transform — it deliberately does NOT
     change the bar's height, because a sticky bar that shrinks on scroll can
     shorten the document, un-shrink itself and oscillate.

   TOKENS FIRST. Every duration, easing and distance is a custom property at
   the top. An owner who wants the whole site calmer sets `--mo-base` once; a
   single page that wants a longer stagger sets `--mo-stagger` on its own
   section. Nobody edits a rule.
   ========================================================================= */

:root {
  /* ---- durations ----
     Named for the JOB, not for the number, so a retune stays coherent.
     --mo-hero is the only value over half a second and it is a one-shot on
     first paint; legibility lands well before it ends (see the hero block). */
  --mo-instant: 90ms;
  --mo-fast:    180ms;
  --mo-base:    420ms;
  --mo-slow:    660ms;
  --mo-hero:    720ms;

  /* ---- the one-shot arrivals, AS TOKENS AND NOT AS NUMBERS ----
     ★ EVERY DURATION BELOW IS READ BY motion.js, AND THAT IS THE POINT.
       These four animations are `backwards`-filled: the element is INVISIBLE
       from the moment the mark is stamped until the animation's own delay has
       elapsed. That hidden phase is a state somebody has to own, so motion.js
       takes each mark back off once the arrival can no longer be running —
       and it can only compute "can no longer be running" if it can read the
       duration. A number typed into the rule below and nowhere else is a
       deadline the engine has to GUESS, and a guess that comes in short cuts
       an arrival in half on the owner who retuned it. */
  --mo-art:     940ms;   /* the hero plate: the last thing to settle */
  --mo-draw:    560ms;   /* a rule or a divider drawing itself in    */
  --mo-ico:     620ms;   /* an icon landing                          */

  /* ---- easings ----
     `--mo-out` is the arrival curve the casino sheet already uses: fast in,
     long settle. Weight is the late deceleration, not a big distance. */
  --mo-out:    cubic-bezier(.2, .7, .3, 1);
  --mo-in:     cubic-bezier(.55, .06, .68, .19);
  --mo-press:  cubic-bezier(.3, 0, .1, 1);      /* fast IN  — the finger */
  --mo-spring: cubic-bezier(.16, 1.02, .28, 1); /* slow OUT — the release */

  /* ---- the geometry of an arrival ----
     18px is far enough to read as travel and short enough that a whole grid
     of it never looks like the page is falling apart. */
  --mo-rise:    18px;
  --mo-rise-sm: 9px;
  --mo-scale:   .985;

  /* ---- stagger ----
     motion.js writes an integer `--mo-i` per element and the delay is
     computed HERE, so the rhythm of a grid is a stylesheet decision. The JS
     clamps the index (see `staggerIndex`) so row 40 of a long list is not
     delayed two seconds. */
  --mo-stagger: 52ms;

  /* The two OTHER rhythms, on the same law as the durations above: the engine
     multiplies these by the same `--mo-i` the sheet does, so its deadline and
     this sheet's delay can never drift apart. */
  --mo-in-step:   88ms;   /* the hero's followers            */
  --mo-line-step: 78ms;   /* line-by-line inside a headline  */

  /* ---- card craft ---- */
  --mo-lift:    -4px;
  --mo-tilt:    5deg;   /* the ceiling; the pointer scales inside it */
  --mo-sweep:   1100ms;
}

/* =========================================================================
   1. THE FAIL-OPEN CONTRACT
   -------------------------------------------------------------------------
   Read this block before changing anything below it.

   ★ THIS BLOCK USED TO CLAIM `[data-wc-reveal="pending"]` WAS THE ONLY RULE IN
     THIS SHEET THAT MAKES CONTENT INVISIBLE. IT WAS NOT, AND THE CLAIM IS WHY
     NOBODY WENT LOOKING. A `backwards`-filled animation with a delay hides its
     element for the whole of that delay, just as flatly as `opacity: 0` does,
     and this sheet has five of them: `[data-wc-in]`, `.wc-line-in`,
     `[data-wc-draw]::before`, `[data-wc-rule]` and `[data-wc-ico]`. The
     difference is not visibility, it is OWNERSHIP — a `pending` element is on
     motion.js's books and a `backwards` fill was on nobody's, so the first
     self-heals and the second could not.

   THE COMPLETE LIST OF WAYS THIS SHEET CAN HIDE SOMETHING, THEREFORE:
     1. `[data-wc-reveal="pending"]`            — an explicit opacity: 0
     2. any mark carrying a `backwards` fill    — hidden for its own delay
   Both are fenced behind `html[data-wc-motion="on"]`, which motion.js writes
   only after proving it can finish the job; and every mark in list 2 now
   carries a DEADLINE in motion.js's ledger, so neither list can strand
   anything. If you add a third way, it goes on a deadline too — the test
   `test_motion.py::NothingTheEngineStagedStaysHidden` derives list 2 out of
   this file automatically and will fail the moment a new one appears with no
   owner.

   The transition lives on the bare attribute so it is present for BOTH state
   changes; the delay is the stagger, computed from the integer motion.js
   stamped on the element.
   ========================================================================= */
html[data-wc-motion="on"] [data-wc-reveal="pending"] {
  opacity: 0;
  transform: translate3d(0, var(--mo-rise), 0) scale(var(--mo-scale));
}
html[data-wc-motion="on"] [data-wc-reveal] {
  transition:
    opacity   var(--mo-base) var(--mo-out),
    transform var(--mo-base) var(--mo-out);
  transition-delay: calc(var(--mo-i, 0) * var(--mo-stagger));
  /* Promoted for the DURATION ONLY. motion.js strips the attribute when the
     arrival ends, which takes this hint with it — a page that leaves
     `will-change` on two hundred cards has handed the compositor two hundred
     layers it will never use again. */
  will-change: opacity, transform;
}
html[data-wc-motion="on"] [data-wc-reveal="in"] {
  opacity: 1;
  transform: none;
}

/* Rows and list items travel less than sections: a table where every line
   slides 18px reads as a page coming apart rather than as a page arriving. */
html[data-wc-motion="on"] [data-wc-reveal="pending"][data-wc-kind="row"] {
  transform: translate3d(0, var(--mo-rise-sm), 0);
}

/* =========================================================================
   2. THE HERO — the one entrance that fires on first paint.
   -------------------------------------------------------------------------
   The headline resolves LINE BY LINE out of a mask. motion.js wraps each line
   box in `.wc-line > .wc-line-in`; if it cannot (markup it does not
   recognise, or a measure that returns nothing) it wraps the whole heading as
   ONE line and the effect degrades to a single wipe instead of failing.

   ★ THE MASK CANNOT CLIP DESCENDANTS. `overflow: hidden` on a line box cuts
     the tail off a `g` and a `y`. The padding/negative-margin pair below buys
     the mask a sixth of an em of room and gives the layout it back, so the
     heading occupies exactly the space it did before it was split.

   ★ AND IT CANNOT COST LEGIBILITY. Every line is fully readable by ~460ms —
     the last line's delay plus its own travel — which is inside the time the
     hero image is still decoding. This is decoration over text that is
     already in the document; it is never a gate and never a spinner.
   ========================================================================= */
.wc-line {
  display: block;
  overflow: hidden;
  padding-bottom: .14em;
  margin-bottom: -.14em;
}
.wc-line-in { display: block; }

html[data-wc-motion="on"] .wc-line-in {
  animation: mo-line-rise var(--mo-hero) var(--mo-out) backwards;
  animation-delay: calc(var(--mo-i, 0) * var(--mo-line-step, 78ms));
}
@keyframes mo-line-rise {
  from { transform: translate3d(0, 112%, 0); opacity: 0; }
  to   { transform: none;                    opacity: 1; }
}

/* The kicker's rule draws out of nothing. `scaleX` from a left origin, never
   a width animation — a 34px rule that animates `width` is a layout pass per
   frame for two pixels of ember. */
html[data-wc-motion="on"] [data-wc-draw]::before {
  animation: mo-draw var(--mo-draw, 560ms) var(--mo-out) backwards;
  animation-delay: var(--mo-draw-delay, 0ms);
  transform-origin: left center;
}
@keyframes mo-draw {
  from { transform: scaleX(0); opacity: 0; }
  to   { transform: scaleX(1); opacity: 1; }
}

/* The lede and the buttons follow the headline on the same stagger clock.
   These are ANIMATIONS, not transitions, and they END at the element's natural
   state. Remove the whole stylesheet and the hero is simply already finished.

   ★ AND YET THIS BLOCK ONCE LOST THE OWNER HIS LOGO, HIS LEDE AND BOTH HERO
     BUTTONS ON EVERY PHONE. The sheet used to say `data-wc-in` was "safe to
     leave on the element forever", reasoning that an animation ends at the
     natural state. The reasoning was right about the END and silent about the
     BEGINNING. `backwards` fill means the `from` frame — `opacity: 0` — is
     applied for the whole of `animation-delay`, and an animation that has not
     started yet has been in its delay phase since it was stamped. If the
     document's animation clock does not advance, the delay never elapses, and
     `opacity: 0` is not a frame of an entrance: it is a permanent state, on
     the masthead of the site, with no owner and no way back.

     MEASURED, not reasoned: at a 390px layout the hero plate reported
     `opacity: 0` with the image fully decoded, `complete=true`, sitting at the
     correct 376x328 — and `mo-in-art` frozen at currentTime 17ms of a 176ms
     delay, four seconds after load. The elements that lost were exactly the
     ones with the LARGEST `--mo-i`, because their delay is longest: the CTA
     (i=3, 264ms) went first, then the lede and the plate (i=2, 176ms). The
     headline survived, and only because motion.js happens to put its markup
     back on a TIMER — the one cleanup on this page that does not need a frame.

     So the mark is no longer permanent. motion.js stages every one of these
     in a ledger with its own deadline — the delay it computes from
     `--mo-in-step` plus the duration it reads from `--mo-slow` / `--mo-art` —
     and strips the attribute on a plain timer once the arrival can no longer
     be running. Removing it is free when the animation finished normally and
     is the whole page back when it never ran. */
html[data-wc-motion="on"] [data-wc-in] {
  animation: mo-in var(--mo-slow) var(--mo-out) backwards;
  animation-delay: calc(var(--mo-i, 0) * var(--mo-in-step, 88ms));
}
@keyframes mo-in {
  from { opacity: 0; transform: translate3d(0, var(--mo-rise), 0); }
  to   { opacity: 1; transform: none; }
}

/* The hero plate lifts in from slightly further back so the picture reads as
   the last thing to settle. */
html[data-wc-motion="on"] [data-wc-in="art"] {
  animation-name: mo-in-art;
  animation-duration: var(--mo-art, 940ms);
}
@keyframes mo-in-art {
  from { opacity: 0; transform: translate3d(0, 20px, 0) scale(.965); }
  to   { opacity: 1; transform: none; }
}

/* =========================================================================
   3. THE NAV
   -------------------------------------------------------------------------
   Three separate jobs, none of which may move a pixel of layout.
   ========================================================================= */

/* ---- the ink underline ----
   ONE element slides between items instead of each item growing its own rule.
   It is driven entirely by two custom properties motion.js writes:
   `--mo-ink-x` (a translate) and `--mo-ink-w` (a scaleX against a 100px base),
   so the whole thing is a single composited transform.

   ★ SCOPED ABOVE 820px ON PURPOSE. Below that, site.css makes `.nav-links`
     `position: absolute` for the phone panel. Declaring `position: relative`
     unconditionally here would land AFTER site.css and detach the dropdown
     from the bar — a one-line stylesheet edit that breaks phone navigation on
     every page. The ink simply does not exist at phone width, where there is
     no row to slide along. */
@media (min-width: 821px) {
  .nav-links:has(.wc-ink) { position: relative; }
  .wc-ink {
    position: absolute;
    left: 0;
    bottom: 3px;
    width: 100px;             /* the scale base; never animated */
    height: 2px;
    border-radius: var(--r-xs);
    background: var(--rule-accent);
    pointer-events: none;
    transform-origin: left center;
    transform: translate3d(var(--mo-ink-x, 0), 0, 0) scaleX(var(--mo-ink-w, 0));
    opacity: var(--mo-ink-o, 0);
    transition:
      transform var(--mo-fast) var(--mo-out),
      opacity   var(--mo-fast) var(--mo-out);
  }
}

/* ---- the condensed bar ----
   ★ NO HEIGHT CHANGE, AND THAT IS THE DESIGN. A sticky bar that shrinks on
     scroll shortens the document, which can move the scroll position back
     above the threshold, which un-shrinks it: a bar that flickers forever at
     one scroll offset. motion.js also carries hysteresis (condense at 64px,
     release at 28px) so even the PAINT change cannot chatter.
   The bar therefore condenses by getting DENSER, not shorter: a deeper
   ground, a harder shadow, and the tagline stepping back out of the way. */
html[data-wc-scrolled="1"] .nav {
  background-color: var(--surface-sink);
  box-shadow: 0 1px 0 var(--edge-sink), 0 20px 34px -24px var(--veil-deep);
  border-bottom-color: var(--border-2);
}
.nav,
.brand-txt span {
  transition:
    background-color var(--mo-base) var(--mo-out),
    box-shadow       var(--mo-base) var(--mo-out),
    border-color     var(--mo-base) var(--mo-out),
    opacity          var(--mo-base) var(--mo-out),
    transform        var(--mo-base) var(--mo-out);
}
html[data-wc-scrolled="1"] .brand-txt span {
  opacity: .35;
  transform: translate3d(0, -1px, 0);
}

/* ---- the scroll progress rule ----
   A hairline across the foot of the bar, scaled from a 0..1 figure motion.js
   writes once per frame. Pure transform; it costs one composited layer for
   the whole page. */
.nav { position: sticky; }        /* restated so ::after has a containing box */
.nav::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: -1px;
  height: 2px;
  background: var(--rule-accent);
  transform-origin: left center;
  transform: scaleX(var(--mo-progress, 0));
  opacity: var(--mo-progress-o, 0);
  pointer-events: none;
  transition: opacity var(--mo-base) var(--mo-out);
}

/* ---- the phone panel ----
   ★ THE ONLY `opacity: 0` HERE LIVES INSIDE `@starting-style`, WHICH BY
     DEFINITION APPLIES FOR ONE FRAME OF AN ENTERING ELEMENT. There is no
     stable state in this block in which the menu is invisible, so no browser,
     no failure and no other script can strand it. A browser without
     `@starting-style` gets the panel with no entrance — which is exactly what
     it has today. */
@media (max-width: 820px) {
  .nav-links[data-open="true"] {
    transition:
      opacity   var(--mo-fast) var(--mo-out),
      transform var(--mo-fast) var(--mo-out);
  }
  @starting-style {
    .nav-links[data-open="true"] {
      opacity: 0;
      transform: translate3d(0, -10px, 0);
    }
  }
  /* Items walk in behind the panel. `backwards` fill holds each link off
     until its own turn instead of flashing the whole list; the end frame is
     the natural state, so the stagger cannot leave a link hidden. */
  html[data-wc-motion="on"] .nav-links[data-open="true"] a {
    animation: mo-in var(--mo-base) var(--mo-out) backwards;
  }
  html[data-wc-motion="on"] .nav-links[data-open="true"] a:nth-child(1) { animation-delay: 20ms; }
  html[data-wc-motion="on"] .nav-links[data-open="true"] a:nth-child(2) { animation-delay: 46ms; }
  html[data-wc-motion="on"] .nav-links[data-open="true"] a:nth-child(3) { animation-delay: 72ms; }
  html[data-wc-motion="on"] .nav-links[data-open="true"] a:nth-child(4) { animation-delay: 98ms; }
  html[data-wc-motion="on"] .nav-links[data-open="true"] a:nth-child(5) { animation-delay: 124ms; }
  html[data-wc-motion="on"] .nav-links[data-open="true"] a:nth-child(6) { animation-delay: 150ms; }
  html[data-wc-motion="on"] .nav-links[data-open="true"] a:nth-child(n+7) { animation-delay: 168ms; }
}

/* =========================================================================
   4. CARDS — lift, sweep, tilt, press.
   -------------------------------------------------------------------------
   Attribute-driven, never class-driven: motion.js decides what counts as a
   card on the page in front of it and stamps `data-wc-card`. A page whose
   markup this sheet has never seen gets nothing, which is the correct result.

   ★ FOCUS GETS EXACTLY WHAT HOVER GETS. Every rule below is written to the
     pair `:hover, :focus-visible` — and where a card is a container the
     `:focus-within` form is included, because the thing a keyboard lands on
     is usually the LINK inside the card, not the card. A keyboard user who
     only ever sees the outline is being told the affordance was not for them.
   ========================================================================= */
[data-wc-card] {
  transition:
    transform  var(--mo-fast) var(--mo-out),
    box-shadow var(--mo-fast) var(--mo-out),
    border-color var(--mo-fast) var(--mo-out);
}

/* `html:not([data-wc-scrolling])` — see "HANDS OFF WHILE THEY READ" below.
   Without JS the attribute never exists, so the guard is always true and this
   rule behaves exactly as it did before it was added. */
@media (hover: hover) and (pointer: fine) {
  html:not([data-wc-scrolling]) [data-wc-card]:hover {
    transform: translate3d(0, var(--mo-lift), 0);
    box-shadow: var(--lift-2);
    border-color: var(--border-2);
  }
}
[data-wc-card]:focus-visible,
[data-wc-card]:focus-within {
  transform: translate3d(0, var(--mo-lift), 0);
  box-shadow: var(--lift-2);
  border-color: var(--border-2);
}

/* The pointer-tracked tilt is a SECOND transform channel and cannot share the
   one above, so the tilt rides an inner wrapper motion.js never creates —
   instead the card composes both in one declaration through custom
   properties. `--mo-rx`/`--mo-ry` default to zero, so a card nobody has
   pointed at is flat and a browser that never fires pointermove is flat too. */
/* ★ THE GUARD IS REPEATED HERE FOR SPECIFICITY, NOT FOR BEHAVIOUR, and it is
   load-bearing. The tilt is a SECOND transform channel that has to beat the
   plain lift above, and it used to win on source order alone - both selectors
   scored 0,2,0 and this one is written later. Adding `html:not(...)` to the
   lift rule raised it to 0,3,1, which would have silently out-ranked the tilt
   and left every card doing the flat lift instead. The guard here restores the
   tie, and source order decides again exactly as before. Change one of these
   selectors and you must change the other. */
@media (hover: hover) and (pointer: fine) {
  html:not([data-wc-scrolling]) [data-wc-card][data-wc-tilt] {
    transform:
      perspective(900px)
      translate3d(0, var(--mo-lift-now, 0px), 0)
      rotateX(var(--mo-rx, 0deg))
      rotateY(var(--mo-ry, 0deg));
    transition:
      transform 120ms linear,
      box-shadow var(--mo-fast) var(--mo-out),
      border-color var(--mo-fast) var(--mo-out);
  }
  /* Leaving the card is a settle, not a snap: the pointer stopped driving it,
     so the curve takes over again. */
  html:not([data-wc-scrolling]) [data-wc-card][data-wc-tilt]:not(:hover) {
    transition:
      transform var(--mo-base) var(--mo-spring),
      box-shadow var(--mo-fast) var(--mo-out),
      border-color var(--mo-fast) var(--mo-out);
  }
}

/* The light sweep. A single band of the owner's own lit edge crossing the
   face once — the card catching a light source, not a shine effect. It rides
   `::after`, which site.css leaves free on every panel class, and it is
   `pointer-events: none` so it can never eat a click on the card's own link. */
[data-wc-card] { position: relative; }
[data-wc-card]::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(
    105deg,
    transparent 38%,
    color-mix(in srgb, var(--edge-lit) 26%, transparent) 50%,
    transparent 62%);
  background-size: 260% 100%;
  background-position: 130% 0;
  transition: opacity var(--mo-fast) var(--mo-out);
}
@media (hover: hover) and (pointer: fine) {
  html:not([data-wc-scrolling]) [data-wc-card]:hover::after {
    opacity: 1; animation: mo-sweep var(--mo-sweep) var(--mo-out) 1;
  }
}
[data-wc-card]:focus-visible::after,
[data-wc-card]:focus-within::after { opacity: 1; animation: mo-sweep var(--mo-sweep) var(--mo-out) 1; }
@keyframes mo-sweep {
  from { background-position: 130% 0; }
  to   { background-position: -30% 0; }
}

/* -------------------------------------------------------------------------
   ★★★ HANDS OFF WHILE THEY READ — the scroll suppression.

   THE DEFECT THIS EXISTS FOR. Hover is a POSITION relationship, not a gesture
   one: the browser re-resolves :hover whenever the element under the pointer
   changes, and scrolling changes that for a pointer that never moved. Wheel
   down a grid of cards and every card on the way past lifts, takes the deeper
   shadow, lights its border and runs the sweep animation - a strobe down the
   page that reads as the cards tearing. Nothing above is wrong on its own;
   they are simply all firing for a reader who is not pointing at anything.

   motion.js writes `data-wc-scrolling` on <html> for as long as a scroll is
   in flight (see its scroll section for the three ways it comes off), so the
   whole of section 4 switches off in one attribute write.

   NO `!important` ANYWHERE. `html[data-wc-scrolling]` adds an element and an
   attribute to each selector, which out-specifies every rule above it - so
   these can be overridden by an owner sheet in the normal way, and nothing
   here can strand a card in a state a later rule cannot reach.

   ★ IT SUBTRACTS ONLY. Every declaration below returns a card to its RESTING
   appearance - the one it has when nobody is pointing at it. There is no
   `opacity` and no `visibility` in this block and there must never be one: a
   scroll flag that can hide content is the failure mode this whole engine is
   built around.
   ------------------------------------------------------------------------- */
   ★ IT IS DONE BY UN-MATCHING THE HOVER RULES, NOT BY OVERRIDING THEM, and
   that distinction is the whole reason this block is correct. The obvious
   version - a high-specificity `html[data-wc-scrolling] [data-wc-card]` that
   re-states `box-shadow` and `border-color` back to rest - has to NAME the
   resting values, and the seven classes motion.js stamps do not share them:
   `.card` rests on `--lift-1`/`--border` and `.status` on `--lift-2`/
   `--border-2`. One value for all seven would repaint half the cards on the
   page every time somebody scrolled - a second glitch shipped as the cure for
   the first. Gating the hover rules instead means the resting style is
   whatever that element's own sheet already says, which is right by
   construction and stays right when an owner restyles a panel.

   `transition: none` is likewise ABSENT on purpose: with the hover rules not
   matching there is nothing to transition away from, and forcing it would
   snap a card that a reader is genuinely hovering while the page happens to
   be settling.
   ------------------------------------------------------------------------- */

/* Press. A card that is also a control gives way under the finger. */
[data-wc-card]:active {
  transform: translate3d(0, -1px, 0) scale(.994);
  transition-duration: var(--mo-instant);
  transition-timing-function: var(--mo-press);
}

/* =========================================================================
   5. BUTTONS — press physics and an ember burst.
   -------------------------------------------------------------------------
   ★ FAST IN, SLOW OUT. site.css transitions `transform .16s` in both
     directions, so a press and its release take the same time — which is not
     how anything with mass behaves. The press is `--mo-instant` on the
     `--mo-press` curve (the finger arrives fast) and the release is
     `--mo-base` on `--mo-spring` (the control recovers). That asymmetry IS
     the weight; it is not a longer animation.
   ========================================================================= */
[data-wc-btn] {
  position: relative;
  overflow: hidden;               /* the burst is clipped to the control */
  transition:
    transform    var(--mo-base) var(--mo-spring),
    border-color var(--mo-fast) var(--mo-out),
    background   var(--mo-fast) var(--mo-out),
    box-shadow   var(--mo-fast) var(--mo-out);
}
[data-wc-btn]:active {
  transform: translate3d(0, 1px, 0) scale(.978);
  transition-duration: var(--mo-instant);
  transition-timing-function: var(--mo-press);
}
@media (hover: hover) and (pointer: fine) {
  [data-wc-btn]:hover { transform: translate3d(0, -2px, 0); }
}
[data-wc-btn]:focus-visible { transform: translate3d(0, -2px, 0); }

/* The burst. `--mo-px`/`--mo-py` are the press point in per-cent of the
   control, written once per press; the ember is the owner's accent, so a
   jungle palette bursts green and an ember one bursts ember. Nothing is
   created in the DOM — a ripple that appends a node per click leaves litter
   on a button somebody holds down. */
[data-wc-btn]::before {
  content: "";
  position: absolute;
  left: var(--mo-px, 50%);
  top: var(--mo-py, 50%);
  width: 8px; height: 8px;
  margin: -4px;
  border-radius: var(--r-round);
  pointer-events: none;
  opacity: 0;
  background: radial-gradient(
    circle,
    color-mix(in srgb, var(--accent-hi) 62%, transparent) 0%,
    color-mix(in srgb, var(--accent) 14%, transparent) 60%,
    transparent 72%);
}
html[data-wc-motion="on"] [data-wc-btn][data-wc-burst]::before {
  animation: mo-burst 620ms var(--mo-out) 1;
}
@keyframes mo-burst {
  from { opacity: .85; transform: scale(1); }
  to   { opacity: 0;   transform: scale(24); }
}

/* Working / done. Both are STATE, so both carry a readable mark that survives
   with every animation frozen: the stripe is the moving part, `aria-busy` and
   the tick are the part a reader gets either way. */
[data-wc-btn][aria-busy="true"] {
  cursor: progress;
  background-image: repeating-linear-gradient(
    115deg,
    color-mix(in srgb, var(--accent) 16%, transparent) 0 10px,
    transparent 10px 20px);
}
html[data-wc-motion="on"] [data-wc-btn][aria-busy="true"] {
  animation: mo-busy 900ms linear infinite;
}
@keyframes mo-busy {
  from { background-position: 0 0; }
  to   { background-position: 46px 0; }
}
html[data-wc-motion="on"] [data-wc-btn][data-wc-done] {
  animation: mo-done 520ms var(--mo-out) 1;
}
@keyframes mo-done {
  0%   { box-shadow: 0 0 0 0 color-mix(in srgb, var(--ok) 55%, transparent); }
  100% { box-shadow: 0 0 0 18px color-mix(in srgb, var(--ok) 0%, transparent); }
}

/* =========================================================================
   6. NUMBERS
   -------------------------------------------------------------------------
   The counting itself is JS (see `countTo`). What lives here is the DELTA
   mark: a value that changed says so for half a second in the direction it
   moved, because a figure that silently swaps between two polls is a change
   nobody saw. Colour is the signal's own token — --ok for a climb, --warn for
   a fall — never a hand-picked green.
   ========================================================================= */
html[data-wc-motion="on"] [data-wc-num][data-wc-delta="up"]   { animation: mo-delta-up   var(--mo-slow) var(--mo-out) 1; }
html[data-wc-motion="on"] [data-wc-num][data-wc-delta="down"] { animation: mo-delta-down var(--mo-slow) var(--mo-out) 1; }
@keyframes mo-delta-up {
  0%   { color: var(--ok); transform: translate3d(0, 2px, 0); }
  100% { color: inherit;   transform: none; }
}
@keyframes mo-delta-down {
  0%   { color: var(--warn); transform: translate3d(0, -2px, 0); }
  100% { color: inherit;     transform: none; }
}

/* A value that is a WORD rather than a number cannot count, so it cross-fades
   instead of blinking. */
html[data-wc-motion="on"] [data-wc-num][data-wc-swap] {
  animation: mo-swap var(--mo-base) var(--mo-out) 1;
}
@keyframes mo-swap {
  0%   { opacity: 0; transform: translate3d(0, 4px, 0); }
  100% { opacity: 1; transform: none; }
}

/* Tabular figures while a number is in flight. Without this a counting figure
   changes WIDTH on every frame as the glyphs change, and the label beside it
   jitters — the single most common tell of a cheap count-up. */
[data-wc-num] { font-variant-numeric: tabular-nums; }

/* =========================================================================
   7. THE LIVE BAR — a heartbeat tied to the poll.
   -------------------------------------------------------------------------
   site.css already breathes the dot on a 2.8s loop, which is a decoration on
   a timer and says nothing. The ring below fires on the ARRIVAL of a payload,
   so the bar is visibly beating at the rate the site is actually hearing from
   the island — and when the feed dies, it stops, which is information.
   ========================================================================= */
.lb-pulse::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: var(--r-round);
  pointer-events: none;
  opacity: 0;
  border: 1px solid color-mix(in srgb, var(--ok) 70%, transparent);
}
.lb-pulse.part::after { border-color: color-mix(in srgb, var(--warn) 70%, transparent); }
.lb-pulse.off::after  { border-color: color-mix(in srgb, var(--bad) 70%, transparent); }

html[data-wc-motion="on"] #wc-livebar[data-wc-beat] .lb-pulse::after {
  animation: mo-beat 1100ms var(--mo-out) 1;
}
@keyframes mo-beat {
  0%   { opacity: .9; transform: scale(1); }
  100% { opacity: 0;  transform: scale(3.6); }
}

/* A scan across the ribbon on each payload: one pass, left to right, at the
   density of a hairline. It is the bar acknowledging that it just heard
   something. */
#wc-livebar { position: fixed; }   /* restated so ::after has a containing box */
#wc-livebar::after {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(
    90deg,
    transparent 42%,
    color-mix(in srgb, var(--accent) 14%, transparent) 50%,
    transparent 58%);
  background-size: 240% 100%;
}
html[data-wc-motion="on"] #wc-livebar[data-wc-beat]::after {
  animation: mo-scan 1400ms var(--mo-out) 1;
}
@keyframes mo-scan {
  0%   { opacity: 1; background-position: 140% 0; }
  100% { opacity: 0; background-position: -40% 0; }
}

/* =========================================================================
   8. RULES, DIVIDERS AND ICONS
   -------------------------------------------------------------------------
   The three accent rules site.css uses to mark what is current — the eyebrow
   bullet, the hero kicker, the current nav tab — draw themselves rather than
   appearing. Icons get ONE quiet arrival, never a loop: a feature tile that
   pulses forever is a page shouting at somebody trying to read it.

   ★ THESE ARE NOT PERMANENT ATTRIBUTES ANY MORE, FOR THE REASON WRITTEN OUT
     AT `[data-wc-in]` ABOVE. Both animations are `backwards`-filled and both
     carry a DELAY, so between the stamp and the delay elapsing the icon and
     the rule are at `opacity: 0` — an invisible state that lasts exactly as
     long as the animation clock refuses to move. motion.js stamps them with a
     deadline of `delay + --mo-ico` / `delay + --mo-draw` and takes them off on
     a timer, which is why both durations are tokens up top rather than numbers
     down here.
   ========================================================================= */
html[data-wc-motion="on"] [data-wc-ico] {
  animation: mo-ico var(--mo-ico, 620ms) var(--mo-spring) backwards;
  animation-delay: var(--mo-ico-delay, 0ms);
}
@keyframes mo-ico {
  0%   { opacity: 0; transform: scale(.72) rotate(-8deg); }
  60%  { opacity: 1; transform: scale(1.06) rotate(2deg); }
  100% { opacity: 1; transform: none; }
}

/* A horizontal divider draws from its left edge. */
html[data-wc-motion="on"] [data-wc-rule] {
  animation: mo-draw var(--mo-draw, 560ms) var(--mo-out) backwards;
  animation-delay: var(--mo-draw-delay, 0ms);
  transform-origin: left center;
}

/* =========================================================================
   9. SKELETONS
   -------------------------------------------------------------------------
   A panel waiting on fetch shimmers instead of sitting empty, and what
   arrives CROSS-FADES over it rather than popping. `data-wc-swap="in"` is a
   one-shot animation ending at the natural state, so a fetch that resolves
   after the sheet is gone still shows its content.
   ========================================================================= */
[data-wc-skel] {
  position: relative;
  overflow: hidden;
  background-color: var(--surface-2);
  border-radius: var(--r-s);
  color: transparent;
}
[data-wc-skel]::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(
    100deg,
    transparent 30%,
    color-mix(in srgb, var(--edge-lit) 18%, transparent) 50%,
    transparent 70%);
  background-size: 240% 100%;
}
html[data-wc-motion="on"] [data-wc-skel]::after {
  animation: mo-shimmer 1500ms linear infinite;
}
@keyframes mo-shimmer {
  from { background-position: 140% 0; }
  to   { background-position: -40% 0; }
}

html[data-wc-motion="on"] [data-wc-swap="in"] {
  animation: mo-crossfade var(--mo-base) var(--mo-out) 1;
}
@keyframes mo-crossfade {
  from { opacity: 0; transform: translate3d(0, 6px, 0); }
  to   { opacity: 1; transform: none; }
}

/* =========================================================================
   10. THE LIGHTNING KICK
   -------------------------------------------------------------------------
   The atmosphere layer (another file, another owner) dispatches `wc:lightning`
   when its sky flashes. motion.js LISTENS and never calls into it, so a site
   with no atmosphere layer simply never sees this element created.

   ★ IT IS A FIXED OVERLAY, NOT A `filter` ON THE PAGE. A `filter` on <body>
     or <main> makes that element a containing block for every `position:
     fixed` descendant, which would tear the nav and the live bar off the
     viewport for the length of the flash. An overlay costs one layer and
     touches nothing.
   ========================================================================= */
.wc-flash {
  position: fixed;
  inset: 0;
  z-index: var(--z-nav);
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(180deg,
    color-mix(in srgb, var(--edge-lit) 22%, transparent),
    transparent 60%);
}
html[data-wc-motion="on"] .wc-flash[data-wc-lit] {
  animation: mo-flash 420ms var(--mo-out) 1;
}
@keyframes mo-flash {
  0%   { opacity: 0; }
  12%  { opacity: 1; }
  100% { opacity: 0; }
}

/* =========================================================================
   11. PAGE TRANSITIONS
   -------------------------------------------------------------------------
   The native cross-document View Transition, declared and nothing more. It
   costs no JavaScript, it cannot delay first paint (the browser owns the
   frame budget and caps its own wait), and back/forward keep working because
   this is the platform's own navigation, not an intercepted one.

   A browser without it gets the JS fallback in motion.js, which NEVER calls
   preventDefault — it fades the outgoing page while the browser navigates
   normally. An intercepted navigation is how a site loses its back button.
   ========================================================================= */
@view-transition { navigation: auto; }

::view-transition-old(root) { animation: mo-vt-out 140ms var(--mo-in) both; }
::view-transition-new(root) { animation: mo-vt-in  260ms var(--mo-out) both; }
@keyframes mo-vt-out { to   { opacity: 0; } }
@keyframes mo-vt-in  { from { opacity: 0; } }

/* ★ THE CHROME IS HELD, NOT RE-DEALT — this is what makes switching tabs read
   as one application rather than as a page reload with a fade over it.

   `root` above captures the ENTIRE page as one image, so the nav bar and the
   live bar were part of the cross-fade: on every tab switch the reader
   watched the furniture that did not change dissolve and come back. Naming
   them lifts each out of the root snapshot into its own transition group, and
   because the old and new copies are identical the group's default animation
   has nothing to travel - the bars simply stay on screen while the page
   underneath them changes.

   A name must be UNIQUE in the document or the browser skips the whole
   transition, which is why these are the two elements chrome.html authors
   exactly once and never a class a page could repeat. Naming an element also
   makes it a containing block for `position: fixed` DESCENDANTS while a
   transition is running - neither of these has one, and nothing may be given
   one without moving it out. Under reduced motion section 12 turns the whole
   mechanism off, names and all. */
.nav        { view-transition-name: wc-nav; }
#wc-livebar { view-transition-name: wc-livebar; }

/* The outgoing fade for browsers with no view transition. It bottoms out at
   .38 and never at 0: a navigation that is refused, blocked by an extension
   or cancelled by the user leaves the reader looking at THIS page, and it has
   to still be a readable page. motion.js also clears the attribute on
   `pageshow`, which is what a back-button restore out of the bfcache fires. */
html[data-wc-motion="on"] body[data-wc-leaving] {
  opacity: .38;
  transition: opacity var(--mo-fast) var(--mo-in);
}

/* The arrival. A plain opacity animation on <body>: it starts at parse time,
   ends at the natural state, and creates a stacking context but NOT a
   containing block, so the fixed nav and live bar stay anchored to the
   viewport through it.

   ★ AND IT BOTTOMS OUT AT .5, NEVER AT 0 — THE SAME LAW `body[data-wc-leaving]`
     ABOVE IS WRITTEN TO, FOR THE SAME REASON, AND IT WAS TEN MILLISECONDS FROM
     COSTING THE WHOLE PAGE. This animation is on <body> itself, so there is no
     attribute for motion.js to take back off and no ledger entry that could
     rescue it: whatever this keyframe says at the moment the animation clock
     stops is what the reader gets, for ever. In the render that found the hero
     defect the clock froze at roughly 250ms — this fade had finished with
     about 10ms to spare. Had it stopped a fraction earlier, `from { opacity:
     0 }` would have handed the owner a completely blank page with every gate
     green, every asset decoded and nothing to find.
     A 240ms fade from .5 reads as an arrival just as clearly, and the worst it
     can ever do is leave the page slightly dim and completely readable. */
@media (prefers-reduced-motion: no-preference) {
  body { animation: mo-page-in 240ms var(--mo-out) 1; }
  @keyframes mo-page-in { from { opacity: .5; } to { opacity: 1; } }
}

/* =========================================================================
   12. THE USER'S OWN SETTINGS — each of these outranks everything above.
   ========================================================================= */

/* ---- reduced motion ----
   ★ CONTENT ARRIVES; IT DOES NOT TRAVEL. motion.js does not mark anything
     pending under `reduce`, so in practice nothing here has anything to
     undo. This block exists for the case the JS cannot cover: a reader who
     turns the setting ON while the page is already open, mid-reveal, with
     elements sitting at `opacity: 0`. The `!important` pair is the guarantee
     that the switch is never a way to lose the page.

   site.css already flattens every duration globally under `reduce`; this
   block adds the STATES that a flattened duration would otherwise freeze. */
@media (prefers-reduced-motion: reduce) {
  html[data-wc-motion="on"] [data-wc-reveal],
  html[data-wc-motion="on"] [data-wc-reveal="pending"] {
    opacity: 1 !important;
    transform: none !important;
    will-change: auto !important;
  }
  .wc-line-in,
  [data-wc-in],
  [data-wc-ico],
  [data-wc-draw]::before,
  [data-wc-rule] {
    animation: none !important;
    opacity: 1 !important;
    transform: none !important;
  }
  [data-wc-card],
  [data-wc-card][data-wc-tilt],
  [data-wc-btn] { transform: none !important; }
  [data-wc-card]::after,
  [data-wc-btn]::before,
  #wc-livebar::after,
  .lb-pulse::after,
  [data-wc-skel]::after,
  .wc-flash { animation: none !important; opacity: 0 !important; }
  /* The skeleton still READS as waiting — it just does not sweep. */
  [data-wc-skel] { background-color: var(--surface-2); }
  .wc-ink { transition: none !important; }
  body[data-wc-leaving] { opacity: 1 !important; }
  @view-transition { navigation: none; }
}

/* ---- reduced transparency ----
   Sweeps and scans are light passing THROUGH a surface. A reader who has
   asked for less transparency has asked for exactly that not to happen. */
@media (prefers-reduced-transparency: reduce) {
  [data-wc-card]::after,
  #wc-livebar::after,
  [data-wc-skel]::after,
  .wc-flash { display: none; }
  [data-wc-btn]::before { display: none; }
}

/* ---- forced colours ----
   Same law site.css states at its foot: forced-colors replaces colour and
   border but NOT background images, so every gradient in this file would
   survive as decoration under a system palette that did not choose it. The
   marks that carry MEANING — the ink underline, the scroll rule — become
   Highlight, which is the user's own "this one" colour. Everything else
   leaves. */
@media (forced-colors: active) {
  [data-wc-card]::after,
  [data-wc-btn]::before,
  #wc-livebar::after,
  [data-wc-skel]::after,
  .wc-flash { display: none; }
  .wc-ink, .nav::after { background: Highlight; forced-color-adjust: none; }
  [data-wc-card]:hover, [data-wc-card]:focus-within { box-shadow: none; }
}

/* ---- print ----
   ★ THE LAST DOOR. A print sheet runs with the page's JS state frozen exactly
     as it stood, so anything still `pending` when somebody hits Ctrl+P would
     print as blank paper. motion.js also force-reveals on `beforeprint`; this
     is the belt to that brace, and it works even if the script is gone. */
@media print {
  [data-wc-reveal],
  [data-wc-reveal="pending"],
  [data-wc-in],
  .wc-line-in,
  [data-wc-skel] {
    opacity: 1 !important;
    transform: none !important;
    animation: none !important;
    color: inherit !important;
  }
  .wc-line { overflow: visible !important; }
  .wc-ink, .nav::after, .wc-flash,
  [data-wc-card]::after, [data-wc-btn]::before,
  #wc-livebar::after, [data-wc-skel]::after { display: none !important; }
}
