/* ============================================================================
   THE SHARED MARKETING MOTION LAYER  ·  served at /assets/motion.css
   LANE DWSURFACES, 2026-08-12 — extending LANE DWMOTION (row 344), which built
   the same layer on the homepage. Governed by THE VISUAL STANDARD §2.5 (public
   surfaces) and THREE SITES §5.5 (per-band direction).

   WHO LINKS THIS, AND WHO DELIBERATELY DOES NOT.
     links it   about.html · failed.html · cram.html · tools/index.html
                tools/{chart,permission,weather,forecast,aviation-weather}.html
     does NOT   landing/index.html — the homepage carries its own inline copy of
                this vocabulary, written by row 344. Folding the two together is
                real and owed work, and it is NOT done here: another lane was
                live in that file on the same afternoon, and one motion layer is
                not worth two sessions writing to one file. Named, not silently
                left. Until it happens this vocabulary exists TWICE, and the two
                copies are meant to agree — change both or neither.
     does NOT   the 30 blog pages. They load no tokens.css, so every var() below
                would resolve to nothing there; `part107-tutor#2` is that gate.
     does NOT   /app/ — see the two-sided law below.

   🔒 THIS FILE IS THE MARKETING SIDE OF THE TWO-SIDED LAW, so it may use the
   page-scale clocks. --mo-page and --mo-stage are marketing-only; app chrome
   (/app/**) stays on §2⑤'s 150/240/300 ceiling. The boundary is ONE-WAY:
   nothing in this file may be copied into app chrome. design-system/tokens.css
   carries the law in full, and it is the file that defines every --mo-* below.

   ⛔ A SURFACE THAT LINKS THIS MUST ALSO LINK /assets/tokens.css. Every duration
   and easing here is a var(--mo-*); with tokens.css absent they are UNDEFINED,
   which does not fail loudly — the animation shorthand is simply invalid and is
   dropped, so `opacity:0` start-states set elsewhere would be all that survives.
   Measured 2026-08-12: about.html, failed.html and cram.html each resolved
   --mo-base to `null` before this lane, which is exactly why linking tokens.css
   is part of the same change and not a tidy-up.

   ⛔ NO HARDCODED HEX AND NO FREEHAND DURATIONS. scripts/test-design-tokens.mjs
   ratchets hex literals; §6.2 rules out off-scale timings. Every number here is
   either a token or a count of one.
   ============================================================================ */

/* ── ① THE GATE ────────────────────────────────────────────────────────────
   Copied from landing/index.html §① (row 344) deliberately, including the
   reasoning, because the property it holds is the whole safety argument:

   EVERY RULE BELOW THAT HIDES ANYTHING IS SCOPED TO html.dw-motion, which a
   small inline <head> script sets only when the visitor has NOT asked for
   reduced motion. There are exactly two ways to get the static page and both
   are COMPLETE, never degraded:
       · no JavaScript / script blocked → class never set → nothing is hidden
       · prefers-reduced-motion: reduce → class never set → nothing is hidden
   All content is in the DOM at load either way; with the gate off it is also
   all ON SCREEN at load.

   ⚠️ NEITHER SCRIPT IS EXTERNAL, AND THE SECOND ONE TAUGHT US WHY.
   The GATE has to run before first paint, so making the thing that decides "is
   anything hidden?" depend on a fetch adds a failure mode to the one mechanism
   that exists to have none. Nine byte-identical copies is the cheaper half of
   that trade (`grep -c` can check them; landing/index.html's is the original).
   🔴 The REVEAL OBSERVER was shipped as /assets/motion.js first, and that was
   wrong for a sharper reason: it split "the script did not run" into TWO events.
   Block or fail just that one file — an extension, a CSP, a flaky asset — and
   the inline gate has ALREADY set html.dw-motion, so `[data-reveal]{opacity:0}`
   below applies with nothing left in the page that can ever remove it. Eight
   sections of failed.html hidden permanently, on a page whose entire safety
   argument is that a script which does not run costs nothing. The file was
   deleted and the observer put back inline on the two surfaces that use it. */

/* ── ② STAGED ENTRANCE — the page arrives in the order it is read ────────── */
html.dw-motion .dw-stage{animation:dw-rise var(--mo-page) var(--mo-ease-out) both}
@keyframes dw-rise{from{opacity:0;transform:translate3d(0,16px,0)}to{opacity:1;transform:none}}

/* The delay ladder. UNIFORM steps of --dw-stagger, unlike the homepage's, and
   the difference is not an oversight: that headline is one sentence with a turn
   in it ("Decipher the airspace" / "where you want to fly"), so its two halves
   land ~140ms apart to honour a beat the copy already has. No headline on these
   surfaces has that turn, and inventing an irregular rhythm for prose that does
   not have one is decoration. Eight rungs, because past eight the last element
   waits half a second for a reason nobody can perceive. */
html.dw-motion .st-1{animation-delay:calc(var(--dw-stagger)*0)}
html.dw-motion .st-2{animation-delay:calc(var(--dw-stagger)*1)}
html.dw-motion .st-3{animation-delay:calc(var(--dw-stagger)*2)}
html.dw-motion .st-4{animation-delay:calc(var(--dw-stagger)*3)}
html.dw-motion .st-5{animation-delay:calc(var(--dw-stagger)*4)}
html.dw-motion .st-6{animation-delay:calc(var(--dw-stagger)*5)}
html.dw-motion .st-7{animation-delay:calc(var(--dw-stagger)*6)}
html.dw-motion .st-8{animation-delay:calc(var(--dw-stagger)*7)}

/* ── ③ SCROLL REVEALS — the argument arrives in order ─────────────────────── */
html.dw-motion [data-reveal]{opacity:0;transform:translate3d(0,18px,0)}
html.dw-motion [data-reveal].is-in{opacity:1;transform:none;
  transition:opacity var(--mo-page) var(--mo-ease-out),transform var(--mo-page) var(--mo-ease-out)}
/* Children of a revealed group step in on --dw-stagger, capped at eight. */
html.dw-motion [data-stagger].is-in>*{animation:dw-rise var(--mo-base) var(--mo-ease-out) both}
html.dw-motion [data-stagger].is-in>*:nth-child(1){animation-delay:calc(var(--dw-stagger)*0)}
html.dw-motion [data-stagger].is-in>*:nth-child(2){animation-delay:calc(var(--dw-stagger)*1)}
html.dw-motion [data-stagger].is-in>*:nth-child(3){animation-delay:calc(var(--dw-stagger)*2)}
html.dw-motion [data-stagger].is-in>*:nth-child(4){animation-delay:calc(var(--dw-stagger)*3)}
html.dw-motion [data-stagger].is-in>*:nth-child(5){animation-delay:calc(var(--dw-stagger)*4)}
html.dw-motion [data-stagger].is-in>*:nth-child(6){animation-delay:calc(var(--dw-stagger)*5)}
html.dw-motion [data-stagger].is-in>*:nth-child(7){animation-delay:calc(var(--dw-stagger)*6)}
html.dw-motion [data-stagger].is-in>*:nth-child(n+8){animation-delay:calc(var(--dw-stagger)*7)}

/* ── ④ THE KEYBOARD RING, AND WHY IT SHIPS IN THE MOTION FILE ─────────────── */
/* §2.5 makes keyboard operability part of the same requirement as the motion:
   the ring is the affordance, the movement is decoration on top of it. Row 344
   found the homepage's #cheatsheet with the UA default ring — a near-black
   hairline on a near-black page, i.e. no ring at all — by reading computed
   style while tabbing rather than by looking at it.
   The same sweep over these nine surfaces, 2026-08-12, found 21 distinct
   focusable shapes with no usable ring, ALL of them on about/failed/cram and
   all from one cause: those pages did not load tokens.css, so --dw-focus-ring
   was undefined. NAV_CSS's `outline:none;box-shadow:var(--dw-focus-ring)` then
   removed the browser's ring and put nothing back — a ring deleted by a rule
   whose replacement never resolved. The tools pages measured 0 failures.
   ⚠️ --dw-focus-ring is TWO layers on purpose: a near-black inner ring and an
   amber outer one. On graphite and navy the amber is the layer you see (8.5:1
   and 8.5:1); on the light cram sheet the amber is only 1.8:1 and the inner
   near-black carries it at 16:1. Measure BOTH layers — an instrument that reads
   only the first shadow reports 1.06:1 everywhere and invents failures. */
/* Unscoped element selectors, deliberately: the set of pages that want this is
   exactly the set that links this file. tools.css already declares the same
   thing for .btn/input/a/summary, and the two declarations are identical, so
   the tools pages gain nothing and lose nothing — they measured clean already.
   The three navy/paper pages are where all 21 gaps were. */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
summary:focus-visible,
[tabindex]:not([tabindex="-1"]):focus-visible{
  outline:none;box-shadow:var(--dw-focus-ring);
  /* 🔴 `transition:none` IS PART OF THE RING, NOT TIDINESS. .dw-lift below animates
     box-shadow on --mo-base, and a control that carries both would FADE ITS FOCUS RING
     IN OVER 240ms — the affordance arriving a quarter-second after the key press, which
     is the one place §2.5 does not permit a clock. Found by reading computed style
     immediately after focusing about.html's .cta and getting
     `rgba(192,57,143,.38) 0 6px 20px, rgba(0,0,0,0) 0 0 0` — the drop shadow with a
     transparent second layer, i.e. the browser mid-interpolation. That reads as a dead
     ring to any instrument that does not know to wait, and as a laggy one to a person. */
  transition:none}

/* ── ⑤ THE BUTTON LADDER — a control should feel like it weighs something ─── */
/* Rest → hover → press on the micro clock, and the press RETURNS the lift so
   the control reads as taking your weight. Opt-in by class, because these nine
   surfaces do not share a button class: about/failed have .cta/.cta2, cram has
   a bare <button> in its toolbar, the tools have .btn from tools.css. */
.dw-lift{transition:transform var(--mo-micro) var(--mo-ease-out),
                    background-color var(--mo-micro) var(--mo-ease-out),
                    border-color var(--mo-micro) var(--mo-ease-out),
                    box-shadow var(--mo-base) var(--mo-ease-out)}
.dw-lift:hover{transform:translate3d(0,-1px,0)}
.dw-lift:active{transform:translate3d(0,1px,0)}

/* ── ⑥ BELT AND BRACES ───────────────────────────────────────────────────── */
/* If the gate ever leaks — a dw-motion class set by something other than the
   head script — this stops every rule above dead. */
@media (prefers-reduced-motion: reduce){
  html.dw-motion .dw-stage,html.dw-motion [data-stagger].is-in>*{animation:none}
  html.dw-motion [data-reveal]{opacity:1;transform:none;transition:none}
  .dw-lift{transition:none}
}

/* 🔴 ⑦ PRINT. THIS IS NOT HOUSEKEEPING — IT IS THE ONE FAILURE MODE THAT LOSES
   CONTENT RATHER THAN POLISH. A print render fires no scroll events, so the
   IntersectionObserver may never mark a section `.is-in`, and `opacity:0` prints
   as a blank band. cram.html exists to be printed; failed.html is the page
   someone prints to take to a retest booking. Everything is forced visible and
   static here, whatever the gate did on screen. Verified by emulating print
   media, not by reasoning about it. */
@media print{
  html.dw-motion .dw-stage,
  html.dw-motion [data-stagger].is-in>*,
  html.dw-motion [data-stagger]>*{animation:none !important}
  html.dw-motion [data-reveal],
  html.dw-motion [data-reveal].is-in{opacity:1 !important;transform:none !important;transition:none !important}
  .dw-lift{transition:none !important}
}
