/* ==========================================================================
   Vizora — motion & material layer
   Baseline cadence: 340ms, cubic-bezier(.16,1,.3,1), 12px travel,
   60ms stagger capped at 6 children, everything fires once.
   Deviations below are individually justified.
   ========================================================================== */

:root{
  --ease-out:   cubic-bezier(.16, 1, .3, 1);   /* expo-out: arrives and settles, no overshoot */
  --ease-soft:  cubic-bezier(0, 0, .2, 1);     /* decelerate: best for opacity alone */
  --ease-snap:  cubic-bezier(.12, 0, .08, 1);  /* tabs, toggles */
  --dur-1: 140ms;   /* hover, focus, press */
  --dur-2: 200ms;
  --dur-3: 340ms;   /* reveals — past ~500ms reads as sluggish */
  --stagger: 60ms;

  --lift-1: 0 1px 2px rgba(11,13,17,.04), 0 1px 3px rgba(11,13,17,.05);
  --lift-2: 0 2px 6px rgba(11,13,17,.05), 0 10px 24px rgba(11,13,17,.07);
  --lift-3: 0 4px 12px rgba(11,13,17,.07), 0 22px 48px rgba(11,13,17,.11);
}

/* --------------------------------------------------------------------------
   1. Scroll reveal
   Default state is VISIBLE. .js-motion is added by script only after it has
   confirmed it can un-hide things again — so no-JS and slow-JS never strand
   content at opacity:0.
   Note: no `will-change` here. A permanent compositor layer on ~60 elements
   costs more than the transition it would accelerate, and the browser can
   already see a declared transition coming.
   -------------------------------------------------------------------------- */
.js-motion [data-reveal]{
  opacity: 0;
  transform: translateY(12px);
  transition: opacity var(--dur-3) var(--ease-soft),
              transform var(--dur-3) var(--ease-out);
  transition-delay: var(--reveal-delay, 0ms);
}
.js-motion [data-reveal].is-in{ opacity: 1; transform: none; }

/* stagger, capped at 6 so item 20 does not wait a second and a half */
.js-motion [data-stagger] > *{ --reveal-delay: calc(var(--i, 0) * var(--stagger)); }

/* --------------------------------------------------------------------------
   2. Material
   The drifting blurred "aurora" blob was removed deliberately: it is both a
   well-known generated-site signature and one of the most expensive things
   you can put on a mid-range phone (blur cost scales with radius x area).
   What replaces it is static, cheap, and reads as engineering drawing.
   -------------------------------------------------------------------------- */

/* fine luminance grain — kills the flat-plastic look on large dark fields */
.grain{ position: relative; isolation: isolate; }
.grain::after{
  content:""; position:absolute; inset:0; z-index:1; pointer-events:none;
  opacity:.42; mix-blend-mode:overlay;
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='.34'/%3E%3C/svg%3E");
}
.grain > *{ position:relative; z-index:2; }

/* hairline of light along the top edge of a dark panel — reads as a surface */
.edge-lit{ position:relative; }
.edge-lit::before{
  content:""; position:absolute; inset:0 0 auto; height:1px; pointer-events:none;
  background:linear-gradient(90deg, transparent, rgba(255,255,255,.12) 22%,
                                     rgba(255,255,255,.12) 78%, transparent);
}

/* static depth wash — no blur filter, no animation, no drifting blob */
.glow-field{ position:relative; }
.glow-field::before{
  content:""; position:absolute; inset:0; pointer-events:none;
  background:
    radial-gradient(60% 50% at 78% 12%, rgba(226,86,29,.16), transparent 68%),
    radial-gradient(48% 46% at 8% 92%, rgba(54,171,128,.07), transparent 70%);
}

/* engineering grid, masked so it fades instead of ending on a hard line */
.gridlines{ position:relative; }
.gridlines::after{
  content:""; position:absolute; inset:0; pointer-events:none; z-index:0;
  background-image:
    linear-gradient(rgba(255,255,255,.04) 1px, transparent 1px),
    linear-gradient(90deg, rgba(255,255,255,.04) 1px, transparent 1px);
  background-size:64px 64px;
  -webkit-mask-image:radial-gradient(ellipse 90% 72% at 28% 0%, #000 22%, transparent 76%);
          mask-image:radial-gradient(ellipse 90% 72% at 28% 0%, #000 22%, transparent 76%);
}

/* --------------------------------------------------------------------------
   3. Micro-interaction — hover effects guarded so they never stick on touch
   -------------------------------------------------------------------------- */
.lift{ transition: transform var(--dur-2) var(--ease-out),
                   box-shadow var(--dur-2) var(--ease-out),
                   border-color var(--dur-2) var(--ease-soft); }

@media (hover: hover) and (pointer: fine){
  .lift:hover{ transform:translateY(-2px); box-shadow:var(--lift-3); }
  .ul-grow:hover::after{ transform:scaleX(1); }
}

.ul-grow{ position:relative; text-decoration:none !important; }
.ul-grow::after{
  content:""; position:absolute; left:0; bottom:-2px; height:1px; width:100%;
  background:currentColor; transform:scaleX(0); transform-origin:left;
  transition:transform var(--dur-2) var(--ease-out);
}

/* Header: background and border only. Animating its HEIGHT reflows the page
   under the reader's thumb, which reads as jitter — so it stays fixed. */
.site-head{
  transition: box-shadow var(--dur-2) var(--ease-soft),
              background-color var(--dur-2) var(--ease-soft),
              border-color var(--dur-2) var(--ease-soft);
}
.site-head.is-stuck{
  box-shadow: var(--lift-2);
  background: rgba(255,255,255,.94);
}
.site-head .brand img{
  transform-origin:left center;
  transition: transform var(--dur-2) var(--ease-out);
}
.site-head.is-stuck .brand img{ transform: scale(.92); }

.count{ font-variant-numeric: tabular-nums; font-feature-settings:"tnum" 1; }

/* --------------------------------------------------------------------------
   4. Signal primitives — the vocabulary of the subject
   pathLength="1" on the path normalises its geometry, so the dash maths below
   is a plain fraction and never needs remeasuring on resize.
   -------------------------------------------------------------------------- */
@keyframes breathe{ 0%,100%{ opacity:.5; transform:scale(1) } 50%{ opacity:1; transform:scale(1.26) } }
@keyframes halo   { 0%{ opacity:.45; transform:scale(1) } 62%,100%{ opacity:0; transform:scale(3.2) } }
@keyframes packet { 0%{ stroke-dashoffset:1; opacity:0 } 4%{ opacity:1 }
                    72%{ opacity:1 } 80%,100%{ stroke-dashoffset:0; opacity:0 } }

.sig-dot { animation:breathe 4.5s var(--ease-soft) infinite; transform-origin:center; transform-box:fill-box; }
.sig-halo{ animation:halo    4.5s var(--ease-out)  infinite; transform-origin:center; transform-box:fill-box; }
.sig-line{ stroke-dasharray:.06 .94; animation:packet 4.6s linear infinite; }
/* speed carries severity — a fault ticks faster than a healthy point */
.is-fault .sig-dot, .is-fault .sig-halo{ animation-duration:1.7s; }

/* --------------------------------------------------------------------------
   5. Reduced motion
   Reset to the END state, never `animation:none` alone — that would strand
   every reveal at opacity:0, invisible and silent.
   -------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce){
  .js-motion [data-reveal]{
    opacity:1 !important; transform:none !important;
    transition:none !important; animation:none !important;
  }
  .sig-dot, .sig-halo, .sig-line{ animation:none !important; }
  .sig-dot{ opacity:.95; }
  .sig-line{ stroke-dasharray:none; opacity:.85; }
  .lift:hover{ transform:none; }
  .site-head.is-stuck .brand img{ transform:none; }
}

/* ==========================================================================
   6. Reveal variants
   Until now every one of the ~60 revealing elements used the same fade-up of
   12px. One uniform effect applied to everything is what makes motion read as
   generic: a photograph, a rule and a paragraph are different objects and
   should not enter the page identically.

   `[data-reveal]` matches any value, so the base rule above still applies to
   all of these — each variant only overrides the FROM transform, and .is-in
   still resets every one of them to `transform:none`. Nothing can strand.
   -------------------------------------------------------------------------- */

/* cards and tiles — a touch more travel than body copy, so a grid of them
   reads as objects arriving rather than text settling */
.js-motion [data-reveal="rise"]{ transform: translateY(22px); }

/* photographs and figures — scale, because an image reads as an object with
   depth, not as a line of text sliding */
.js-motion [data-reveal="scale"]{ transform: scale(.955); }

/* split layouts — the two halves come from their own sides */
.js-motion [data-reveal="left"]  { transform: translateX(-22px); }
.js-motion [data-reveal="right"] { transform: translateX(22px); }

/* rules and dividers draw themselves rather than fading in */
.js-motion [data-reveal="draw"]{
  opacity:1; transform: scaleX(0); transform-origin: left center;
  transition: transform 520ms var(--ease-out);
}

/* dense prose — movement under a block of text is distracting, so opacity
   alone. Used for the blog posts, which are read rather than scanned. */
.js-motion [data-reveal="fade"]{ transform: none; }

/* table rows and list items, revealed individually.
   The cap matters: 24 rows x 60ms would make the last one wait 1.4s, so the
   stagger shortens as the list grows. */
.js-motion [data-stagger-rows] tbody tr,
.js-motion [data-stagger-rows] > li{
  opacity:0; transform: translateY(8px);
  transition: opacity 300ms var(--ease-soft), transform 300ms var(--ease-out);
  transition-delay: calc(var(--i, 0) * 34ms);
}
.js-motion [data-stagger-rows].is-in tbody tr,
.js-motion [data-stagger-rows].is-in > li{ opacity:1; transform:none; }

/* --------------------------------------------------------------------------
   7. Page transitions
   The old pair was a 160ms linear cut-out against a 220ms ease-in, which read
   as a snap rather than a change of view. Matching the curves and letting the
   outgoing page hold a moment makes navigation feel continuous.
   -------------------------------------------------------------------------- */
@keyframes vt-out2{ from{ opacity:1; transform:none }
                    to  { opacity:0; transform:translateY(-6px) scale(.994) } }
@keyframes vt-in2 { from{ opacity:0; transform:translateY(8px)  scale(.996) }
                    to  { opacity:1; transform:none } }

@media (prefers-reduced-motion: no-preference){
  ::view-transition-old(root){ animation: vt-out2 200ms var(--ease-soft) both; }
  ::view-transition-new(root){ animation: vt-in2  320ms var(--ease-out)  both; }
}

/* Every variant above must come to rest under a reduced-motion preference,
   not merely stop animating — otherwise a `draw` stays at scaleX(0), which is
   invisible, and a `scale` stays shrunk. */
@media (prefers-reduced-motion: reduce){
  .js-motion [data-reveal],
  .js-motion [data-stagger-rows] tbody tr,
  .js-motion [data-stagger-rows] > li{
    opacity:1 !important; transform:none !important;
    transition:none !important; animation:none !important;
  }
}
