/* ==========================================================================
   KidZaps — Image Loading UX (2026)
   ==========================================================================
   Purpose
   -------
   Show a shimmering skeleton in every image slot the moment the page paints,
   BEFORE the actual bytes finish downloading. This solves two long-standing
   user complaints:

       1. "Cards look empty until the images pop in."
       2. "The images seem to appear a little at a time, like a bad scan."

   How it works
   ------------
   Wrap every `<img>` in a `.kz-img-wrap` element. That wrapper carries the
   shimmer via a `::after` pseudo-element, so the skeleton is visible even
   BEFORE the `<img>` element has laid itself out (which is what causes the
   "empty white blob" flash on slow connections).

   • CSS-only shimmer — 60fps, GPU-composited transform (never touches paint).
   • Aspect-ratio locked at the wrapper level → zero Cumulative Layout Shift.
   • Optional dominant-colour background via `--kz-dominant` CSS variable —
     falls back to a neutral grey so it always looks intentional.
   • JS toggles `.kz-loaded` on the wrapper (see assets/js/image-loader.js);
     if JS is disabled we still show the image (opacity fallback below).

   Contract with markup
   --------------------
       <div class="kz-img-wrap" style="--kz-dominant: #dbe4f5;">
           <img src="…" class="kz-loading"
                width="400" height="500"
                loading="lazy" decoding="async" alt="…">
       </div>

   • `.kz-loading` on the <img>       → applied by view helpers.
   • `.kz-loaded`  on the wrapper     → added by image-loader.js on load.
   • `.kz-error`   on the wrapper     → added by image-loader.js on error.

   NOTE: All rules are scoped under `.kz-img-wrap` so this file cannot leak
   into any pre-existing markup that already uses <img> directly.
   ========================================================================== */


/* -------------------------------------------------------------------------
   Wrapper — always fills its parent and hosts the shimmer overlay.
   -------------------------------------------------------------------------
   Two entry points:
     1. `.kz-img-wrap`               — explicit opt-in (new views)
     2. `.worksheet-thumb-wrapper`   — implicit opt-in for legacy views
                                       (home / search / related / author).
     3. `.author-avatar`             — matches the round avatar used in
                                       worksheet + blog + author templates.
                                       Only when it wraps an actual <img>.
   All three share the SAME shimmer + fade behaviour so we don't have to
   touch dozens of view files. The JS delegate adds `.kz-loaded` to any
   ancestor matching either selector when an <img> inside reports load.
   ------------------------------------------------------------------------- */
.kz-img-wrap,
.worksheet-thumb-wrapper:has(> img),
.worksheet-thumb-wrapper:has(> iframe) {
    position: relative;
    display: block;
    width: 100%;
    overflow: hidden;
    /* Neutral fallback if no --kz-dominant is provided inline. */
    background-color: var(--kz-dominant, #e5e7eb);
    /* Smooth colour transition when a dark theme kicks in mid-load. */
    transition: background-color 0.25s ease;
    isolation: isolate;
}

/* Preserve any aspect-ratio the caller assigns via inline style / class. */
.kz-img-wrap.kz-img-wrap--card {
    aspect-ratio: 4 / 5;
}

.kz-img-wrap.kz-img-wrap--avatar {
    aspect-ratio: 1 / 1;
    border-radius: 50%;
}

.kz-img-wrap.kz-img-wrap--wide {
    aspect-ratio: 16 / 9;
}


/* -------------------------------------------------------------------------
   Shimmer overlay
   -------------------------------------------------------------------------
   GPU-friendly:
     • ONE property is animated — `transform` — so the browser can push it
       onto its own compositor layer (no repaint, no layout).
     • `will-change: transform` is intentionally OMITTED. On low-end Android
       devices it forces a large fixed-size layer per card that eats VRAM
       and actually hurts scrolling on 20+ cards. The transform alone is
       enough to hint compositing.
   ------------------------------------------------------------------------- */
@keyframes kz-shimmer {
    0% {
        transform: translateX(-100%);
    }

    100% {
        transform: translateX(100%);
    }
}

.kz-img-wrap::after,
.worksheet-thumb-wrapper:has(> img)::after,
.worksheet-thumb-wrapper:has(> iframe)::after {
    content: '';
    position: absolute;
    inset: 0;
    z-index: 1;
    pointer-events: none;
    transform: translateX(-100%);
    background: linear-gradient(90deg,
            transparent 0%,
            rgba(255, 255, 255, 0.55) 50%,
            transparent 100%);
    animation: kz-shimmer 1.6s cubic-bezier(0.4, 0.0, 0.2, 1) infinite;
}

/* Kill the shimmer as soon as the image reports "load". */
.kz-img-wrap.kz-loaded::after,
.kz-img-wrap.kz-error::after,
.worksheet-thumb-wrapper.kz-loaded::after,
.worksheet-thumb-wrapper.kz-error::after {
    animation: none;
    display: none;
}


/* -------------------------------------------------------------------------
   The image itself — fades in once ready
   -------------------------------------------------------------------------
   Fade is deliberately short (180ms) — long fades feel "slow", short fades
   feel "snappy". 180ms is the sweet spot in Nielsen's UX research.
   ------------------------------------------------------------------------- */
/*
 * Fade-in ONLY applies to the modern `.kz-img-wrap` container. Legacy
 * `.worksheet-thumb-wrapper` markup (home, search, related, author) does
 * not add `.kz-loading` to its <img>, so we keep those images fully
 * visible by default and rely purely on the shimmer overlay + JS `.kz-loaded`
 * class to disappear once the image has loaded. This way legacy views
 * automatically get the shimmer skeleton WITHOUT any template changes.
 */
.kz-img-wrap>img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    opacity: 0;
    transition: opacity 0.18s ease-out;
    /* Prevent ghosting on iOS Safari during the fade. */
    -webkit-transform: translateZ(0);
    transform: translateZ(0);
}

/* Fallback for the small chance JS never runs (client-side error, blocked,
   very old browser). We show the image after 3s regardless via a keyframe
   forcing `opacity: 1` so users are never stuck with a blank card. */
@keyframes kz-force-visible {
    to {
        opacity: 1;
    }
}

.kz-img-wrap>img.kz-loading {
    animation: kz-force-visible 0s linear 3s forwards;
}

.kz-img-wrap.kz-loaded>img,
.kz-img-wrap.kz-error>img {
    opacity: 1;
}


/* -------------------------------------------------------------------------
   Error state — replaces shimmer with a tasteful "broken image" glyph.
   Uses a data-URI SVG so it works even when the network fails completely.
   ------------------------------------------------------------------------- */
.kz-img-wrap.kz-error {
    display: flex;
    align-items: center;
    justify-content: center;
    /* Hide the broken <img> so we don't render the browser's default icon
       on top of our friendly fallback. */
    color: #9ca3af;
}

.kz-img-wrap.kz-error>img {
    /* Hide the failed image element itself, but keep it in the DOM so a11y
       tools can still read the alt text. */
    width: 0;
    height: 0;
    opacity: 0;
}

.kz-img-wrap.kz-error::before {
    content: '';
    width: 42%;
    height: 42%;
    background-color: currentColor;
    -webkit-mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><rect x='3' y='3' width='18' height='18' rx='2' ry='2'/><circle cx='8.5' cy='8.5' r='1.5'/><polyline points='21 15 16 10 5 21'/></svg>");
    mask-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'><rect x='3' y='3' width='18' height='18' rx='2' ry='2'/><circle cx='8.5' cy='8.5' r='1.5'/><polyline points='21 15 16 10 5 21'/></svg>");
    -webkit-mask-repeat: no-repeat;
    mask-repeat: no-repeat;
    -webkit-mask-position: center;
    mask-position: center;
    -webkit-mask-size: contain;
    mask-size: contain;
    opacity: 0.55;
}


/* -------------------------------------------------------------------------
   Iframe skeleton support
   -------------------------------------------------------------------------
   The category card also supports an <iframe> preview when the worksheet
   has no thumbnail but does have an html_template. Iframes fire `load`
   later than <img>, so we treat the iframe's load event identically to
   an image's — the same JS delegate covers both.
   ------------------------------------------------------------------------- */
.kz-img-wrap>iframe {
    display: block;
    width: 100%;
    height: 100%;
    border: 0;
    opacity: 0;
    transition: opacity 0.22s ease-out;
    /* pointer-events kept off so the surrounding <a> click still works. */
    pointer-events: none;
}

.kz-img-wrap.kz-loaded>iframe {
    opacity: 1;
}


/* -------------------------------------------------------------------------
   Dark theme adjustments — the shimmer highlight must be dimmer or it
   looks like a torch on a black wall. Handled via the site's data-theme
   attribute (matches existing convention in worksheet-show.php).
   ------------------------------------------------------------------------- */
:root[data-theme="dark"] .kz-img-wrap,
html[data-theme="dark"] .kz-img-wrap,
body[data-theme="dark"] .kz-img-wrap,
.dark .kz-img-wrap {
    background-color: var(--kz-dominant-dark, #1f2937);
}

:root[data-theme="dark"] .kz-img-wrap::after,
html[data-theme="dark"] .kz-img-wrap::after,
body[data-theme="dark"] .kz-img-wrap::after,
.dark .kz-img-wrap::after {
    background: linear-gradient(90deg,
            transparent 0%,
            rgba(255, 255, 255, 0.08) 50%,
            transparent 100%);
}


/* -------------------------------------------------------------------------
   Reduced-motion — respect the user's OS-level preference.
   Replaces the sweep with a subtle static pulse so we still communicate
   "loading" without any horizontal motion.
   ------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
    @keyframes kz-shimmer-pulse {

        0%,
        100% {
            opacity: 0.35;
        }

        50% {
            opacity: 0.65;
        }
    }

    .kz-img-wrap::after {
        animation: kz-shimmer-pulse 1.8s ease-in-out infinite;
        transform: none;
        background: linear-gradient(90deg,
                transparent 0%,
                rgba(255, 255, 255, 0.35) 50%,
                transparent 100%);
    }
}


/* -------------------------------------------------------------------------
   Performance — content-visibility on card grids
   -------------------------------------------------------------------------
   Rows below the viewport get skipped by the browser's rendering pipeline
   until they scroll close. `contain-intrinsic-size` reserves the exact
   pixel height so the scrollbar never jumps.

   Applied at the CARD level (not the wrapper) so lazy-loaded images inside
   still trigger their own load event when they're scrolled into view.
   ------------------------------------------------------------------------- */
.worksheet-card {
    content-visibility: auto;
    contain-intrinsic-size: auto 380px;
}

/* Never defer the first row of cards — those are the LCP candidates. */
.worksheet-grid>.worksheet-card:nth-child(-n+4) {
    content-visibility: visible;
}