/* ============================================================================
   TierLogic — the six motion primitives
   ----------------------------------------------------------------------------
   ONE shared set. Every animated feature scene on the homepage composes from
   these six moves and adds no keyframes of its own.

   WHY SIX. Measured on clickup.com at 390x844 on 2026-08-02: nine animated
   feature scenes, 0 <video>, 0 <canvas>, no Lottie, no WASM. Every visual is
   ordinary DOM + SVG driven by CSS @keyframes. Reading the ~18 keyframe names
   across all nine, the same small vocabulary repeats — spin, pulse, cycle,
   reveal, draw, shimmer. Nine bespoke-looking scenes reduce to six moves.
   See /internal/architecture/tierlogic-website/planning/nine-section-feature-pattern §2.

   For contrast, from the same catalogue: huly.io (R-08) spends 377 KB of WASM
   on one decorative border; dimension.dev (R-07) ships 24.3 MiB of video for
   its section media. This file is the whole motion budget for every scene.

   WHAT IS IN HERE. Exactly six @keyframes and six classes. Nothing else
   animates. If a scene needs a seventh move, that is a decision, not a patch:
   add it here and nowhere else.

   NO COLOUR. This file sets no colour, font, size or spacing — motion only.
   Palette sign-off is still open, and this set does not wait on it.

   🔴 REDUCED MOTION (D49). Every primitive is inert under
   `prefers-reduced-motion: reduce`. Not optional. The block at the end of this
   file is the enforcement; read the note there before touching it, because
   "inert" is NOT the same as `animation: none`.

   TUNING. Every knob is a custom property, so a scene tunes a primitive from
   its own rule and never re-declares an animation:

       .scene .hub { --tl-pulse-dur: 3s; --tl-pulse-min: .5; }

   STAGGER. Anything that fires in sequence takes `--tl-i` (0, 1, 2, ...),
   usually inline: <li class="tl-reveal" style="--tl-i:2">
   ============================================================================ */

:root{
  /* shared easing — one "enters" curve, one "breathes" curve */
  --tl-ease:       cubic-bezier(.22,.61,.36,1);
  --tl-ease-inout: cubic-bezier(.65,0,.35,1);

  /* the gap between two staggered items */
  --tl-stagger: 90ms;
}


/* ----------------------------------------------------------------------------
   🔴 SVG: transforms need a box, or they are measured against the wrong thing.
   ----------------------------------------------------------------------------
   Inside an `<svg>`, `transform-box` defaults to `view-box`. So a `scale()` or
   `rotate()` on a child pivots around the SVG's own ORIGIN rather than around
   the element, and a percentage `translate()` resolves against the VIEWBOX
   rather than the element. Five of the six primitives move a transform, so
   five of the six are silently wrong on any SVG child without this.

   Measured on the Connected Apps scene before this rule existed: all 7 pulsing
   nodes displaced, drift up to 235 user units on a 640-unit viewBox — the
   status dot rendered outside its own badge. The animation was running
   perfectly; it was just pivoting around the top-left corner of the drawing.

   🔑 It is invisible in a code review and invisible in an inertness test. It
   only appears when you render the thing and measure where the marks landed,
   which is why `verify-motion.mjs` now asserts node positions.

   `:where(svg *)` scopes this to SVG children and adds no specificity, so HTML
   elements keep their normal border-box origin and nothing else moves.
   ---------------------------------------------------------------------------- */

.tl-spin:where(svg *),
.tl-pulse:where(svg *),
.tl-cycle:where(svg *),
.tl-reveal:where(svg *),
.tl-shimmer:where(svg *){
  transform-box: fill-box;
  transform-origin: center;
}


/* ============================================================================
   1. SPIN — rotate a mark, usually the logo
   ----------------------------------------------------------------------------
   Seen on the reference as: contextBrainSpin, spinIcon, brainIconSpin,
   memorySpin. Four names, one move.

       <img class="tl-spin" src="mark.svg" alt="">

   Knobs   --tl-spin-dur   12s     one full revolution
           --tl-spin-turn  1turn   use -1turn to reverse

   🔴 SPIN OWNS `transform` ON ITS ELEMENT. The `to` frame replaces the whole
   transform, so a translate or scale set on the same element is discarded the
   moment the animation starts. Put spin on a wrapper if you need to combine.
   ============================================================================ */

@keyframes tl-spin{
  to{ transform: rotate(var(--tl-spin-turn, 1turn)); }
}

.tl-spin{
  animation: tl-spin var(--tl-spin-dur, 12s) linear infinite;
  animation-delay: calc(var(--tl-i, 0) * var(--tl-stagger));
}


/* ============================================================================
   2. PULSE — glow in and out, breathe
   ----------------------------------------------------------------------------
   Seen as: dotPulse, ghostPulse, borderPulse, glowDrift, borderBrighten.
   Five names, one move. Use it for "this is live" — a status dot, a halo
   behind a hub, a border that brightens.

       <span class="tl-pulse"></span>

   Knobs   --tl-pulse-dur   2.4s
           --tl-pulse-min   .35    opacity at rest
           --tl-pulse-max   1      opacity at peak
           --tl-pulse-to    1.08   scale at peak (1 = opacity only)

   The peak is the legible state, so it is also the reduced-motion state.
   ============================================================================ */

@keyframes tl-pulse{
  0%, 100%{
    opacity:   var(--tl-pulse-min, .35);
    transform: scale(var(--tl-pulse-from, 1));
  }
  50%{
    opacity:   var(--tl-pulse-max, 1);
    transform: scale(var(--tl-pulse-to, 1.08));
  }
}

.tl-pulse{
  animation: tl-pulse var(--tl-pulse-dur, 2.4s) var(--tl-ease-inout) infinite;
  animation-delay: calc(var(--tl-i, 0) * var(--tl-stagger));
}


/* ============================================================================
   3. CYCLE — step through a set, one at a time
   ----------------------------------------------------------------------------
   Seen as: contextWindowCycle, intelligenceCycle. Provider chips swapping
   under one steady label; window cards stepping behind a mark.

   Built as a TRACK, not as N fading children. A track of N stacked items
   inside a one-item window, translated by `steps(N)`, lands exactly on each
   item and wraps — so ONE keyframes serves any N, with no per-item rules and
   no JavaScript.

       <div class="tl-cycle-window" style="--tl-cycle-h:2rem">
         <ul class="tl-cycle" style="--tl-cycle-count:3">
           <li>Claude</li><li>GPT</li><li>Gemini</li>
         </ul>
       </div>

   Knobs   --tl-cycle-count  3      MUST equal the real number of children
           --tl-cycle-slot   2.4s   dwell per item
           --tl-cycle-h      2rem   height of one item = height of the window

   🔴 `--tl-cycle-count` is the one knob that is not cosmetic. Set it wrong and
   the track lands between items — a half-visible row rather than a clean swap.
   It is the thing to check first when a cycle looks broken.
   ============================================================================ */

@keyframes tl-cycle{
  to{ transform: translate3d(0, -100%, 0); }
}

.tl-cycle{
  animation: tl-cycle
             calc(var(--tl-cycle-slot, 2.4s) * var(--tl-cycle-count, 3))
             steps(var(--tl-cycle-count, 3))
             infinite;
}

/* Structural companion, not a primitive — it animates nothing. The window has
   to clip, or every item in the track is visible at once and the cycle reads
   as a list. */
.tl-cycle-window{
  overflow: hidden;
  height: var(--tl-cycle-h, 2rem);
}
.tl-cycle-window > .tl-cycle > *{
  height: var(--tl-cycle-h, 2rem);
}


/* ============================================================================
   4. REVEAL — slide/fade an item in, on a stagger
   ----------------------------------------------------------------------------
   Seen as: chipReveal, slideInFromLeft, slideInFromRight, card-in. Four names,
   one move — the direction is a variable, not a second keyframes.

       <li class="tl-reveal" style="--tl-i:0">Improve billing error handling</li>
       <li class="tl-reveal" style="--tl-i:1">Optimize dashboard load time</li>

   Knobs   --tl-reveal-dur  .55s
           --tl-reveal-y    10px   travel; use 0 for a pure fade
           --tl-reveal-x    0      set instead of -y to come from the side
           --tl-i           0      stagger index

   `both` fill: the item holds the hidden state through its delay and holds the
   revealed state afterwards, so it never flashes at frame 0 and never reverts.
   ============================================================================ */

@keyframes tl-reveal{
  from{
    opacity: 0;
    transform: translate3d(var(--tl-reveal-x, 0), var(--tl-reveal-y, 10px), 0);
  }
  to{
    opacity: 1;
    transform: translate3d(0, 0, 0);
  }
}

.tl-reveal{
  animation: tl-reveal var(--tl-reveal-dur, .55s) var(--tl-ease) both;
  animation-delay: calc(var(--tl-i, 0) * var(--tl-stagger));
}


/* ============================================================================
   5. DRAW — trace a line or a border into existence
   ----------------------------------------------------------------------------
   Seen as: drawLine, drawCardBorderFromTop, drawCardBorderFromBottom. Three
   names, one move: a dashed stroke whose offset runs to zero.

   🔑 Put `pathLength="100"` on the SVG shape. That normalises any path — a
   line, an arc, a rounded rect — to a 0-100 scale, so the default length below
   is correct for every shape and NOTHING has to measure geometry in JS. This
   is the whole reason three reference keyframes collapse into one here.

       <path class="tl-draw" pathLength="100" style="--tl-i:1" d="..."/>

   Knobs   --tl-draw-dur  1.2s
           --tl-draw-len  100    leave alone if you set pathLength="100"
           --tl-draw-cap  butt   🔴 set `round` on any CLOSED shape — see below
           --tl-i         0      stagger index

   🔴 CLOSED SHAPES NEED `--tl-draw-cap: round`. A dash exactly as long as the
   path covers all of it, but its two BUTT ends meet at the path's start point
   without joining, and a rounded rect leaves a visible notch there — measured
   at 6x on the hub badge, a clean break in the outline. Round caps overlap by
   half the stroke width and close it. Open paths keep `butt`, which is what
   makes a wire end flat instead of blobbed.

   Direction: a border that draws "from the bottom" is the same keyframes on a
   reversed path — change `d`, not the animation.
   ============================================================================ */

@keyframes tl-draw{
  to{ stroke-dashoffset: 0; }
}

.tl-draw{
  stroke-dasharray:  var(--tl-draw-len, 100);
  stroke-dashoffset: var(--tl-draw-len, 100);
  stroke-linecap:    var(--tl-draw-cap, butt);
  animation: tl-draw var(--tl-draw-dur, 1.2s) var(--tl-ease) both;
  animation-delay: calc(var(--tl-i, 0) * var(--tl-stagger));
}


/* ============================================================================
   6. SHIMMER — move a gradient or a strip, continuously
   ----------------------------------------------------------------------------
   Seen as: shimmerStream, skillsMarquee, typing-shimmer. One move, two uses,
   and the only difference is how far it travels:

   a) MARQUEE — a strip of logos. Duplicate the content once inside the track
      and travel -50%: at the end, copy 2 sits exactly where copy 1 started, so
      the loop is seamless.

        <div class="tl-shimmer-window">
          <div class="tl-shimmer" style="--tl-shimmer-dist:-50%">
            ...items... ...the same items again...
          </div>
        </div>

   b) SHEEN — a gradient band swept across a surface. Size the band wider than
      its box, park it off the left edge, and travel a full width-plus.

        <span class="tl-shimmer" style="--tl-shimmer-dist:200%"></span>

   Knobs   --tl-shimmer-dur   14s    slow. A fast marquee reads as an error
           --tl-shimmer-dist  -50%   -50% for a doubled track, 200% for a sheen

   🔴 Travel with `transform`, never `background-position` or `left`. Transform
   is the only one of the three the compositor can run off the main thread, and
   this is the one primitive that runs forever.
   ============================================================================ */

@keyframes tl-shimmer{
  to{ transform: translate3d(var(--tl-shimmer-dist, -50%), 0, 0); }
}

.tl-shimmer{
  animation: tl-shimmer var(--tl-shimmer-dur, 14s) linear infinite;
  will-change: transform;
}

/* Structural companion, not a primitive. */
.tl-shimmer-window{
  overflow: hidden;
}


/* ============================================================================
   🔴 D49 — REDUCED MOTION. EVERY PRIMITIVE IS INERT.
   ----------------------------------------------------------------------------
   D49 (2026-08-02) records that air.inc IGNORES this flag — measured with the
   flag confirmed active — and that we explicitly do not inherit that. D49 is
   written against `#demo`; this file applies the same promise to all six
   primitives, because a visitor who asked for less motion did not ask only
   about one section.

   🔴 INERT IS NOT `animation: none`. Every rule below does two things: it
   stops the animation AND pins the element to its FINISHED, legible state.

   🔑 Which of the six actually needs the pin — MEASURED, not assumed.
   `animation: none` drops an element back to its BASE computed style, so a
   hidden state that lives only inside a `from` keyframe disappears with the
   animation and is harmless. A hidden state declared in the CLASS RULE does
   not: it stays, forever, with nothing left to animate it away.

   Only ONE primitive is in the second category — `tl-draw`, which sets
   `stroke-dashoffset: var(--tl-draw-len)` on the class itself so the stroke is
   hidden before the animation starts. Poison run 2026-08-02 (`--poison=
   animation-none-only`, the pins removed and `animation: none` kept):
   **draw failed, the other five passed.** Draw came back `stroke-dashoffset:
   100` — every wire in the Connected Apps scene invisible, permanently, for
   exactly the visitors who asked for less motion.

   🔴 The other five pins are kept anyway, and they are not decoration: they
   are what stops a scene rule that sets its own `opacity` or `transform` at
   higher specificity from re-hiding an element the moment the animation is
   gone. Cheap, uniform, and they cost 5 lines.

   This failure is silent. The page looks perfect to everyone who tests it
   without the flag set, which is everyone, unless a check like the one below
   runs with the flag ON.

   🔴 `!important` is deliberate here, on both halves. This is a user-agency
   override, and it has to beat a scene rule that sets its own animation or
   opacity at higher specificity. It is the one place in this file where
   `!important` is correct.

   To verify:  node deploy/verify-motion.mjs
   It asserts both halves, on the fixture and on the real scene, and it has
   been shown failing:
       node deploy/verify-motion.mjs --poison=no-reduced-motion    → 8 red
       node deploy/verify-motion.mjs --poison=animation-none-only  → 2 red
   Both poisons edit the CSS in memory only; the file on disk is never written.
   ============================================================================ */

/* 🔴 EVERY SELECTOR BELOW IS GATED ON `html:not(.force-motion)`, and the gate
   changes nothing for a visitor. `.force-motion` is set only by
   _preview-motion.js, which is a mockup-only file, defaults to OFF, and is
   dropped by build-homepage.mjs before the page is assembled.

   It exists because an operator whose OS has animation effects switched off has
   `reduce` reported to them by Chrome on every site, so all fifteen mockups
   render their static poster frame and the motion cannot be reviewed at all.
   The delivered TL 6 Cards preview gates its own reduce block exactly this way.

   🔑 The guard never adds the class and never touches localStorage, so cases B,
   K, P, Q, T, U, V, W, X, Y, Z, AA, AC and AD still measure the real inert
   state. Both CSS poisons key on the `@media (prefers-reduced-motion: reduce)`
   string, which is untouched, so they still land. */
@media (prefers-reduced-motion: reduce){

  html:not(.force-motion) .tl-spin,
  html:not(.force-motion) .tl-pulse,
  html:not(.force-motion) .tl-cycle,
  html:not(.force-motion) .tl-reveal,
  html:not(.force-motion) .tl-draw,
  html:not(.force-motion) .tl-shimmer{
    animation: none !important;
  }

  /* ...and each pinned to its finished state. */

  html:not(.force-motion) .tl-spin    { transform: none !important; }

  html:not(.force-motion) .tl-pulse   { opacity: var(--tl-pulse-max, 1) !important;
                transform: none !important; }

  /* track parked on item 1; the window still clips the rest */
  html:not(.force-motion) .tl-cycle   { transform: none !important; }

  html:not(.force-motion) .tl-reveal  { opacity: 1 !important;
                transform: none !important; }

  /* stroke fully drawn */
  html:not(.force-motion) .tl-draw    { stroke-dashoffset: 0 !important; }

  html:not(.force-motion) .tl-shimmer { transform: none !important;
                will-change: auto !important; }
}
