/* ky-textfx — companion stylesheet for ky/ky-textfx.js.

   A reusable TEXT-ANIMATION engine, not a section. Four effects:
     .kyt-dust   particle "black hole" formation over live text (canvas)
     [data-kyt-typed]  letter-scramble load-in (no styling needed, JS-only)
     .kyt-rv     opacity + rise reveal (and the stagger built from it)
     .kyt-mask   scroll-linked clip-path wipe with a ghost layer behind

   HOUSE LAWS OBSERVED
     - no new palette: every effect paints in currentColor / the --color-*
       tokens it inherits, so all 12 themes work with zero per-theme code.
     - no new type scale: nothing here sets a font-size. The dust canvas
       SAMPLES the host's own computed montrealMono at its own size.
     - motion vocabulary: the reveal/mask easing is framer-motion's own
       "easeOut" = cubic-bezier(0,0,.58,1), which is the easing the original
       Zuned Aalim bundle uses for every word/char reveal. The site's house
       overshoot cubic-bezier(.51,.92,.24,1.15) is available per-call.
     - z-index: nothing here is fixed and nothing stacks. The dust canvas is
       a local absolutely-positioned sibling of the text it replaces.

   THE NEVER-INVISIBLE LAW (the whole reason this file is written this way)
   -----------------------------------------------------------------------
   The classic failure of this pattern is CSS that hides text and JS that
   never un-hides it. Here that is structurally impossible:

     1. EVERY hidden state below is gated on `html.kyt-on`. ky-textfx.js adds
        that class exactly once, at boot, INSIDE a try, and only after the
        geometry watchdog interval that can un-hide everything is already
        installed. Script blocked / throws at parse / 404s -> class absent ->
        every selector below fails to match -> the page is plain live text.
     2. The two scrub-driven customs default to their VISIBLE value
        (--kyt-wipe: 0%), so an unstarted or dead scrub reads as "done", not
        as "hidden".
     3. The dust host only goes transparent under [data-kyt-dust="live"],
        which JS writes only AFTER the first successful canvas frame and
        removes the moment the render heartbeat stops.
     4. Print and forced-colors reveal everything unconditionally.
*/

/* ============================================================================
   reveal / stagger
   Both share one register: opacity + a small rise. --kyt-rise/-dur/-delay are
   written per element by JS so one class covers every call site.
   ========================================================================= */
html.kyt-on .kyt-rv {
  opacity: 0;
  transform: translateY(var(--kyt-rise, 24px));
  will-change: opacity, transform;
}
html.kyt-on .kyt-rv.kyt-in {
  opacity: 1;
  transform: none;
  will-change: auto;
  transition:
    opacity   var(--kyt-dur, 700ms) var(--kyt-ease, cubic-bezier(0, 0, 0.58, 1)) var(--kyt-delay, 0ms),
    transform var(--kyt-dur, 700ms) var(--kyt-ease, cubic-bezier(0, 0, 0.58, 1)) var(--kyt-delay, 0ms);
}

/* ============================================================================
   mask — scroll-linked clip-path wipe
   The ink is clipped from the right by --kyt-wipe (100% = fully wiped away,
   0% = fully revealed). It DEFAULTS to 0%: if the scrub never runs, or the
   rAF driver dies, the text is simply all there.
   The ghost is the same words at 10% behind the ink, so the un-wiped part of
   the line is still legible as a shadow of itself — the reference's own trick.
   ========================================================================= */
.kyt-mask { position: relative; }
html.kyt-on .kyt-mask > .kyt-mask-ink {
  display: block;
  clip-path: inset(0 var(--kyt-wipe, 0%) 0 0);
  will-change: clip-path;
}
.kyt-mask > .kyt-mask-ghost {
  position: absolute;
  inset: 0;
  z-index: 0;
  opacity: 0.1;
  pointer-events: none;
  user-select: none;
}
/* The ghost's words are generated content, so el.textContent is unchanged by
   masking — other ky modules compare textContent to decide whether to rebuild
   a node, and a duplicated string would put them into a rebuild loop. */
.kyt-mask > .kyt-mask-ghost::after { content: attr(data-kyt-ghost); }
.kyt-mask > .kyt-mask-ink { position: relative; z-index: 1; }

/* ============================================================================
   dust — particle formation
   The host keeps its REAL text (screen readers, SEO, select/copy, and the
   fallback render). The canvas is decorative and aria-hidden. The real text
   is only made transparent — never display:none, never visibility:hidden —
   so its box keeps reserving exactly the space the canvas measures against
   and there is zero CLS when the effect tears down.
   ========================================================================= */
.kyt-dust { position: relative; }

/* the ink wrapper is display:contents-adjacent: it must not change layout */
.kyt-dust > .kyt-dust-ink { display: inline; }

html.kyt-on .kyt-dust[data-kyt-dust="live"] > .kyt-dust-ink {
  color: transparent;
  /* keep the caret and selection highlight readable if a user drag-selects */
  -webkit-text-fill-color: transparent;
}

.kyt-dust > canvas.kyt-dust-cv {
  position: absolute;
  inset: calc(-1 * var(--kyt-dust-pad, 28px));
  display: block;
  pointer-events: none;
  z-index: 0;
  /* the canvas never paints until JS has drawn a frame into it */
  opacity: 0;
  transition: opacity 0.25s linear;
}
.kyt-dust[data-kyt-dust="live"] > canvas.kyt-dust-cv { opacity: 1; }

/* ---- the host's ink filter has to come off while the dust is live ----------
   index.html declares
       html.honeykit:not(.nokitfx) .ky-heroname { filter:url(#goo-tight) url(#roughen) }
   and a CSS filter applies to the element's WHOLE subtree — which, once dust
   is armed, includes canvas.kyt-dust-cv. #goo-tight is a blur followed by an
   alpha threshold (the standard gooey-blob recipe): it is built to weld fat
   adjacent shapes together and to DELETE anything too small or too sparse to
   survive the threshold. The dust field is thousands of ~1.5px motes with air
   between them, i.e. precisely the input that recipe erases. The wordmark was
   therefore rendering as smeared ink with no visible particles even on the
   frames where the effect was alive and painting.

   Scoped to [data-kyt-dust="live"], which the JS only stamps after the first
   painted frame, so the plain-ink wordmark keeps its goo weld right up until
   the moment the canvas takes over — and the swap is invisible because the
   rule above has already made the real text transparent by then. Honey & Ink
   never arms dust, so its ink treatment is untouched. */
.kyt-dust[data-kyt-dust="live"] { filter: none !important; }

/* ---- and the heading must stop clipping the sampling pad -------------------
   canvas.kyt-dust-cv is inset by -var(--kyt-dust-pad) (30px on the wordmark)
   so particles have somewhere to fly in FROM. index.html's wordmark line is
   `<h1 class="… overflow-clip">`, and a slide-reveal clip on the line box cuts
   that pad off on all four sides: the dust appeared to spawn out of the
   letters instead of gathering into them. ky-w-singularity.css makes the same
   repair on the world pages (`.kys-name { overflow: visible }`) and records
   the same reason; this is the index-side half of it.

   Scoped through :has() to the live state and to heading elements only, so it
   costs nothing until dust is actually painting and the clip goes straight
   back when the effect tears down. */
:is(h1, h2, h3, h4, .kys-name):has(> .kyt-dust[data-kyt-dust="live"]) {
  overflow: visible;
}

/* ============================================================================
   reduced motion / reduced transparency
   JS bails out of every entry point on its own (no canvas is built, no class
   is added, the scramble writes the final string immediately). This block is
   the ky-flow / ky-motion style belt-and-braces double guard.
   ========================================================================= */
@media (prefers-reduced-motion: reduce) {
  html.kyt-on .kyt-rv,
  html.kyt-on .kyt-rv.kyt-in {
    opacity: 1 !important;
    transform: none !important;
    transition: none !important;
    will-change: auto;
  }
  html.kyt-on .kyt-mask > .kyt-mask-ink { clip-path: none !important; }
  .kyt-mask > .kyt-mask-ghost { display: none; }
  html.kyt-on .kyt-dust[data-kyt-dust="live"] > .kyt-dust-ink {
    color: inherit;
    -webkit-text-fill-color: currentColor;
  }
  .kyt-dust > canvas.kyt-dust-cv { display: none; }
}

@media (prefers-reduced-transparency: reduce) {
  .kyt-mask > .kyt-mask-ghost { opacity: 0.22; }
}

/* ============================================================================
   print + forced colors — always the plain, complete words
   ========================================================================= */
@media print {
  html.kyt-on .kyt-rv,
  html.kyt-on .kyt-rv.kyt-in { opacity: 1 !important; transform: none !important; }
  html.kyt-on .kyt-mask > .kyt-mask-ink { clip-path: none !important; }
  .kyt-mask > .kyt-mask-ghost,
  .kyt-dust > canvas.kyt-dust-cv { display: none !important; }
  html.kyt-on .kyt-dust[data-kyt-dust="live"] > .kyt-dust-ink {
    color: inherit; -webkit-text-fill-color: currentColor;
  }
}

@media (forced-colors: active) {
  /* a canvas is invisible in forced-colors; hand the words straight back */
  .kyt-dust > canvas.kyt-dust-cv { display: none !important; }
  html.kyt-on .kyt-dust[data-kyt-dust="live"] > .kyt-dust-ink {
    color: inherit; -webkit-text-fill-color: currentColor;
  }
  html.kyt-on .kyt-mask > .kyt-mask-ink { clip-path: none !important; }
}
